Compare commits

...

88 Commits

Author SHA1 Message Date
opencode 1ee8a9c0b2 release: v1.1.26 2026-01-19 21:55:27 +00:00
Adam 4e04bee0c9 fix(app): favicon 2026-01-19 15:46:04 -06:00
Spoon 673e79f457 tweak(batch): up restrictive max batch tool from 10 to 25 (#9275) 2026-01-19 15:44:58 -06:00
Adam 79ae749ed8 fix(app): don't change resize handle on hover 2026-01-19 15:28:37 -06:00
Filip d605a78a05 fix(app): change keybind for cycling thinking effort (#9508) 2026-01-19 15:15:43 -06:00
GitHub Action 69b3b35ea5 chore: generate 2026-01-19 21:00:39 +00:00
Adam 3173ba1288 fix(app): fade under sticky elements 2026-01-19 14:59:50 -06:00
Adam a4d1824412 fix(app): no more favicons 2026-01-19 14:59:47 -06:00
Adam cac35bc52d fix(app): global terminal/review pane toggles 2026-01-19 14:59:46 -06:00
Adam ecc51ddb4e fix(app): hash nav 2026-01-19 14:59:46 -06:00
Aiden Cline 769c97af08 chore: rm double conditional 2026-01-19 14:49:51 -06:00
GitHub Action e29120317f chore: generate 2026-01-19 20:47:09 +00:00
Ronan Kearns 88c5a7fe9e fix(tui): clarify resume session tip (#9490) 2026-01-19 14:46:32 -06:00
Joseph Campuzano 091e88c1e1 fix(opencode): sets input mode based on whether mouse vs keyboard is in use to prevent mouse events firing (#9449) 2026-01-19 14:46:17 -06:00
Filip d19e76d96c fix: keyboard nav when mouse hovered over list (#9500) 2026-01-19 14:43:32 -06:00
Filip c3393ecc6c fix(app): give feedback when trying to paste a unsupported filetype (#9452) 2026-01-19 14:16:25 -06:00
Ryan Vogel 889c60d63b fix(web): rename favicons to v2 for cache busting (#9492) 2026-01-19 15:04:59 -05:00
Ariane Emory c47699536f fix: Don't unnecessarily wrap lines and introduce an unneeded empty line (resolves #9489) (#9488) 2026-01-19 13:56:24 -06:00
Adam c2f9fd5fef fix(app): reload instance after workspace reset 2026-01-19 12:44:41 -06:00
Aiden Cline 3fd0043d19 chore: handle fields other than reasoning_content in interleaved block 2026-01-19 12:18:17 -06:00
Adam 092428633f fix(app): layout jumping 2026-01-19 11:44:20 -06:00
Adam fc50b2962c fix(app): make terminal sessions scoped to workspace 2026-01-19 11:28:24 -06:00
Aiden Cline dd0906be8c tweak: apply patch description 2026-01-19 11:22:00 -06:00
David Hill b72a00eaa3 fix text field border showing through focus ring 2026-01-19 17:10:27 +00:00
David Hill 2dbdd18483 add hover overlay with upload/trash icons to project icon in edit dialog 2026-01-19 17:10:27 +00:00
David Hill b0794172bf update: tighten edit project color spacing 2026-01-19 17:10:27 +00:00
David Hill 9fbf2e72b4 update: constrain edit project dialog width 2026-01-19 17:10:27 +00:00
David Hill 494e8d5be9 update: tweak edit project icon container 2026-01-19 17:10:27 +00:00
David Hill e12b94d91a update: adjust edit project icon helper text 2026-01-19 17:10:27 +00:00
David Hill 89be504abc update: align edit project dialog padding and avatar styles 2026-01-19 17:10:27 +00:00
David Hill c7f0cb3d2d fix: remove focus outline from dropdown menu 2026-01-19 17:10:26 +00:00
Adam eb779a7cc5 chore: cleanup 2026-01-19 10:55:57 -06:00
Adam c720a2163c chore: cleanup 2026-01-19 10:55:57 -06:00
Adam 7811e01c8e fix(app): new layout improvements 2026-01-19 10:55:57 -06:00
Adam befd0f1636 feat(app): new session layout 2026-01-19 10:55:57 -06:00
Adam 1f11a8a6ea feat(app): improved session layout 2026-01-19 10:55:57 -06:00
Goni Zahavy d5ae8e0bef fix(opencode): cargo fmt is formatting whole workspace instead of edited file (#9436) 2026-01-19 10:48:59 -06:00
GitHub Action 453417ed47 chore: generate 2026-01-19 16:46:09 +00:00
Joseph Campuzano 72cb7ccc00 fix(app): list component jumping when mouse happens to be under the list and keyboard navigating. (#9435) 2026-01-19 10:43:27 -06:00
Adam 4ee540309f fix(app): hide settings button 2026-01-19 10:26:21 -06:00
Aiden Cline 5b86724632 fix: cargo fmt actually does not support formatting single files 2026-01-19 10:15:32 -06:00
paulclou b1684f3d12 fix(config): rename uv formatter from 'uv format' to 'uv' for config consistency (#9409)
Co-authored-by: Paul C. Lou <paul@exig.ai>
2026-01-19 09:59:51 -06:00
Vladimir Glafirov 29e206b6c6 docs: Improve Gitlab self-hosted instances documentation (#9391) 2026-01-19 09:51:27 -06:00
Evgenii Kosenko 31864cadb4 docs: update codecompanion.nvim acp doc (#9411) 2026-01-19 09:50:41 -06:00
Frank 843d76191e zen: fix black reset date 2026-01-19 10:12:50 -05:00
Github Action 3186e7ec7c Update node_modules hashes 2026-01-19 09:03:52 -06:00
Adam 1ba7c606e6 chore: cleanup 2026-01-19 09:03:52 -06:00
Adam f00f18b926 chore: cleanup 2026-01-19 09:03:52 -06:00
Adam e9ede70793 chore: cleanup 2026-01-19 09:03:52 -06:00
Adam 2b086f0584 test(app): more e2e tests 2026-01-19 09:03:52 -06:00
Adam b90315bc7e chore: cleanup 2026-01-19 09:03:52 -06:00
Adam 182c43a78f chore: cleanup 2026-01-19 09:03:52 -06:00
Adam f1daf3b430 fix(app): tests in ci 2026-01-19 09:03:52 -06:00
Adam dd19c3d8f2 test(app): e2e utilities 2026-01-19 09:03:52 -06:00
Github Action f5eb90514a Update node_modules hash (aarch64-darwin) 2026-01-19 09:03:52 -06:00
Github Action 6bc823bd40 Update node_modules hash (x86_64-linux) 2026-01-19 09:03:52 -06:00
Github Action 7621c5cafb Update flake.lock 2026-01-19 09:03:52 -06:00
Adam 91a708b12e test(app): more e2e tests 2026-01-19 09:03:52 -06:00
Adam 19d15ca4df test(app): more e2e tests 2026-01-19 09:03:52 -06:00
Adam 03d7467ea2 test(app): initial e2e test setup 2026-01-19 09:03:52 -06:00
GitHub Action 23e9c02a7f chore: generate 2026-01-19 13:37:19 +00:00
Adam 51804a47e9 chore: cleanup 2026-01-19 07:35:52 -06:00
Adam 55739b7aa1 chore: cleanup 2026-01-19 07:35:52 -06:00
Adam 295f290efd chore: cleanup 2026-01-19 07:35:52 -06:00
Adam 1a262c4ca8 chore: cleanup 2026-01-19 07:35:52 -06:00
Adam dca2540ca7 chore: cleanup 2026-01-19 07:35:52 -06:00
Adam fcfe6d3d26 chore: cleanup 2026-01-19 07:35:52 -06:00
Adam 093a3e7876 feat(app): reset worktree 2026-01-19 07:35:52 -06:00
Adam f26de6c52f feat(app): delete workspace 2026-01-19 07:35:52 -06:00
GitHub Action 06d03dec3b ignore: update download stats 2026-01-19 2026-01-19 12:05:55 +00:00
Mani Sundararajan 08005d755b refactor(desktop): tweak share button to prevent layout shift (#9322) 2026-01-19 04:34:40 -06:00
Slone 13276aee82 fix(desktop): apply getComputedStyle polyfill on all platforms (#9369) 2026-01-19 04:32:41 -06:00
Aiden Cline 4299450d7d tweak apply_patch tool description 2026-01-19 01:31:30 -06:00
Aiden Cline 3515b4ff7d omit todo tools for openai models 2026-01-19 01:06:26 -06:00
Aiden Cline 4a7809f600 add proper variant support to copilot 2026-01-19 00:18:42 -06:00
GitHub Action 9d1803d000 chore: generate 2026-01-19 06:14:40 +00:00
Caleb Norton 91787ceb3e fix: nix ci - swapped dash/underscore (#9352) 2026-01-19 00:14:14 -06:00
Aiden Cline 86df915df0 chore: cleanup provider code to assign copilot sdk earlier in flow 2026-01-19 00:13:58 -06:00
GitHub Action 6f847a794b chore: generate 2026-01-19 06:12:36 +00:00
NateSmyth 260ab60c0b fix: track reasoning by output_index for copilot compatibility (#9124)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-01-19 00:11:54 -06:00
Aiden Cline e2f1f4d81e add scheduler, cleanup module (#9346) 2026-01-18 23:33:23 -06:00
Christopher Tso fc6c9cbbd2 fix(github-copilot): auto-route GPT-5+ models to Responses API (#5877)
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-18 23:30:28 -06:00
Thiago Malek 6b481b5fb0 fix(opencode): use streamObject when using openai oauth in agent generation (#9231) 2026-01-18 23:22:31 -06:00
Caleb Norton 2fc4ab9687 ci: simplify nix hash updates (#9309) 2026-01-18 21:46:00 -06:00
Luke Parker d939a3ad54 feat(tui): use mouse for permission buttons (#9305) 2026-01-18 21:42:10 -06:00
Frank bee2f65409 zen: fix checkout link for black users 2026-01-18 19:19:00 -05:00
Luke Parker e81bb86795 fix: Windows evaluating text on copy (#9293) 2026-01-18 17:27:30 -06:00
Alan Pogrebinschi b4d4a1ea7d docs: clarify agent tool access and explore vs general distinction (#9300) 2026-01-18 16:46:04 -06:00
121 changed files with 2962 additions and 989 deletions
+65
View File
@@ -18,6 +18,54 @@ jobs:
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Install Playwright browsers
working-directory: packages/app
run: bunx playwright install --with-deps
- name: Seed opencode data
working-directory: packages/opencode
run: bun script/seed-e2e.ts
env:
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json
OPENCODE_DISABLE_MODELS_FETCH: "true"
OPENCODE_DISABLE_SHARE: "true"
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home
XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share
XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache
XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config
XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state
OPENCODE_E2E_PROJECT_DIR: ${{ github.workspace }}
OPENCODE_E2E_SESSION_TITLE: "E2E Session"
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e"
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano"
- name: Run opencode server
run: bun run dev -- --print-logs --log-level WARN serve --port 4096 --hostname 0.0.0.0 &
env:
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json
OPENCODE_DISABLE_MODELS_FETCH: "true"
OPENCODE_DISABLE_SHARE: "true"
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home
XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share
XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache
XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config
XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state
OPENCODE_CLIENT: "app"
- name: Wait for opencode server
run: |
for i in {1..60}; do
curl -fsS "http://localhost:4096/global/health" > /dev/null && exit 0
sleep 1
done
exit 1
- name: run
run: |
git config --global user.email "bot@opencode.ai"
@@ -26,3 +74,20 @@ jobs:
bun turbo test
env:
CI: true
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json
OPENCODE_DISABLE_MODELS_FETCH: "true"
OPENCODE_DISABLE_SHARE: "true"
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home
XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share
XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache
XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config
XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state
PLAYWRIGHT_SERVER_HOST: "localhost"
PLAYWRIGHT_SERVER_PORT: "4096"
VITE_OPENCODE_SERVER_HOST: "localhost"
VITE_OPENCODE_SERVER_PORT: "4096"
OPENCODE_CLIENT: "app"
timeout-minutes: 30
+29 -144
View File
@@ -10,129 +10,18 @@ on:
- "bun.lock"
- "package.json"
- "packages/*/package.json"
- "flake.lock"
- ".github/workflows/update-nix-hashes.yml"
pull_request:
paths:
- "bun.lock"
- "package.json"
- "packages/*/package.json"
- "flake.lock"
- ".github/workflows/update-nix-hashes.yml"
jobs:
compute-node-modules-hash:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
strategy:
fail-fast: false
matrix:
include:
- system: x86_64-linux
host: blacksmith-4vcpu-ubuntu-2404
- system: aarch64-linux
host: blacksmith-4vcpu-ubuntu-2404-arm
- system: x86_64-darwin
host: macos-15-intel
- system: aarch64-darwin
host: macos-latest
runs-on: ${{ matrix.host }}
env:
SYSTEM: ${{ matrix.system }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
ref: ${{ github.head_ref || github.ref_name }}
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- name: Compute node_modules hash
run: |
set -euo pipefail
DUMMY="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
HASH_FILE="nix/hashes.json"
OUTPUT_FILE="hash-${SYSTEM}.txt"
export NIX_KEEP_OUTPUTS=1
export NIX_KEEP_DERIVATIONS=1
BUILD_LOG=$(mktemp)
TMP_JSON=$(mktemp)
trap 'rm -f "$BUILD_LOG" "$TMP_JSON"' EXIT
if [ ! -f "$HASH_FILE" ]; then
mkdir -p "$(dirname "$HASH_FILE")"
echo '{"nodeModules":{}}' > "$HASH_FILE"
fi
# Set dummy hash to force nix to rebuild and reveal correct hash
jq --arg system "$SYSTEM" --arg value "$DUMMY" \
'.nodeModules = (.nodeModules // {}) | .nodeModules[$system] = $value' "$HASH_FILE" > "$TMP_JSON"
mv "$TMP_JSON" "$HASH_FILE"
MODULES_ATTR=".#packages.${SYSTEM}.default.node_modules"
DRV_PATH="$(nix eval --raw "${MODULES_ATTR}.drvPath")"
echo "Building node_modules for ${SYSTEM} to discover correct hash..."
echo "Attempting to realize derivation: ${DRV_PATH}"
REALISE_OUT=$(nix-store --realise "$DRV_PATH" --keep-failed 2>&1 | tee "$BUILD_LOG" || true)
BUILD_PATH=$(echo "$REALISE_OUT" | grep "^/nix/store/" | head -n1 || true)
CORRECT_HASH=""
if [ -n "$BUILD_PATH" ] && [ -d "$BUILD_PATH" ]; then
echo "Realized node_modules output: $BUILD_PATH"
CORRECT_HASH=$(nix hash path --sri "$BUILD_PATH" 2>/dev/null || true)
fi
# Try to extract hash from build log
if [ -z "$CORRECT_HASH" ]; then
CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)"
fi
if [ -z "$CORRECT_HASH" ]; then
CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)"
fi
# Try to hash from kept failed build directory
if [ -z "$CORRECT_HASH" ]; then
KEPT_DIR=$(grep -oE "build directory.*'[^']+'" "$BUILD_LOG" | grep -oE "'/[^']+'" | tr -d "'" | head -n1 || true)
if [ -z "$KEPT_DIR" ]; then
KEPT_DIR=$(grep -oE '/nix/var/nix/builds/[^ ]+' "$BUILD_LOG" | head -n1 || true)
fi
if [ -n "$KEPT_DIR" ] && [ -d "$KEPT_DIR" ]; then
HASH_PATH="$KEPT_DIR"
[ -d "$KEPT_DIR/build" ] && HASH_PATH="$KEPT_DIR/build"
if [ -d "$HASH_PATH/node_modules" ]; then
CORRECT_HASH=$(nix hash path --sri "$HASH_PATH" 2>/dev/null || true)
fi
fi
fi
if [ -z "$CORRECT_HASH" ]; then
echo "Failed to determine correct node_modules hash for ${SYSTEM}."
cat "$BUILD_LOG"
exit 1
fi
echo "$CORRECT_HASH" > "$OUTPUT_FILE"
echo "Hash for ${SYSTEM}: $CORRECT_HASH"
- name: Upload hash artifact
uses: actions/upload-artifact@v6
with:
name: hash-${{ matrix.system }}
path: hash-${{ matrix.system }}.txt
retention-days: 1
commit-node-modules-hashes:
needs: compute-node-modules-hash
update-node-modules-hashes:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: blacksmith-4vcpu-ubuntu-2404
env:
@@ -147,6 +36,9 @@ jobs:
ref: ${{ github.head_ref || github.ref_name }}
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- name: Configure git
run: |
git config --global user.email "action@github.com"
@@ -159,54 +51,47 @@ jobs:
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
git pull --rebase --autostash origin "$BRANCH"
- name: Download all hash artifacts
uses: actions/download-artifact@v7
with:
pattern: hash-*
merge-multiple: true
- name: Merge hashes into hashes.json
- name: Compute all node_modules hashes
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
echo "Merging hashes into ${HASH_FILE}..."
for SYSTEM in $SYSTEMS; do
echo "Computing hash for ${SYSTEM}..."
BUILD_LOG=$(mktemp)
trap 'rm -f "$BUILD_LOG"' EXIT
shopt -s nullglob
files=(hash-*.txt)
if [ ${#files[@]} -eq 0 ]; then
echo "No hash files found, nothing to update"
exit 0
fi
# The updater derivations use fakeHash, so they will fail and reveal the correct hash
UPDATER_ATTR=".#packages.x86_64-linux.${SYSTEM}_node_modules"
EXPECTED_SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
for sys in $EXPECTED_SYSTEMS; do
if [ ! -f "hash-${sys}.txt" ]; then
echo "WARNING: Missing hash file for $sys"
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)"
fi
done
for f in "${files[@]}"; do
system="${f#hash-}"
system="${system%.txt}"
hash=$(cat "$f")
if [ -z "$hash" ]; then
echo "WARNING: Empty hash for $system, skipping"
continue
if [ -z "$CORRECT_HASH" ]; then
echo "Failed to determine correct node_modules hash for ${SYSTEM}."
cat "$BUILD_LOG"
exit 1
fi
echo " $system: $hash"
jq --arg sys "$system" --arg h "$hash" \
'.nodeModules = (.nodeModules // {}) | .nodeModules[$sys] = $h' "$HASH_FILE" > "${HASH_FILE}.tmp"
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 merged:"
echo "All hashes computed:"
cat "$HASH_FILE"
- name: Commit ${{ env.TITLE }} changes
+1
View File
@@ -204,3 +204,4 @@
| 2026-01-16 | 4,121,550 (+552,622) | 1,754,418 (+109,056) | 5,875,968 (+661,678) |
| 2026-01-17 | 4,389,558 (+268,008) | 1,805,315 (+50,897) | 6,194,873 (+318,905) |
| 2026-01-18 | 4,627,623 (+238,065) | 1,839,171 (+33,856) | 6,466,794 (+271,921) |
| 2026-01-19 | 4,861,108 (+233,485) | 1,863,112 (+23,941) | 6,724,220 (+257,426) |
+25 -15
View File
@@ -22,7 +22,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.25",
"version": "1.1.26",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -56,6 +56,7 @@
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "1.57.0",
"@tailwindcss/vite": "catalog:",
"@tsconfig/bun": "1.0.9",
"@types/bun": "catalog:",
@@ -70,7 +71,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.25",
"version": "1.1.26",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -104,7 +105,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.25",
"version": "1.1.26",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -131,7 +132,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.25",
"version": "1.1.26",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -155,7 +156,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.25",
"version": "1.1.26",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -179,7 +180,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.25",
"version": "1.1.26",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -208,7 +209,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.25",
"version": "1.1.26",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -237,7 +238,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.25",
"version": "1.1.26",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -253,7 +254,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.25",
"version": "1.1.26",
"bin": {
"opencode": "./bin/opencode",
},
@@ -357,7 +358,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.25",
"version": "1.1.26",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -377,7 +378,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.25",
"version": "1.1.26",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.4",
"@tsconfig/node22": "catalog:",
@@ -388,7 +389,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.25",
"version": "1.1.26",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -401,7 +402,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.25",
"version": "1.1.26",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -442,7 +443,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.25",
"version": "1.1.26",
"dependencies": {
"zod": "catalog:",
},
@@ -453,7 +454,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.25",
"version": "1.1.26",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -502,6 +503,7 @@
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.0.2",
"@playwright/test": "1.51.0",
"@solid-primitives/storage": "4.3.3",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
@@ -1355,6 +1357,8 @@
"@planetscale/database": ["@planetscale/database@1.19.0", "", {}, "sha512-Tv4jcFUFAFjOWrGSio49H6R2ijALv0ZzVBfJKIdm+kl9X046Fh4LLawrF9OMsglVbK6ukqMJsUCeucGAFTBcMA=="],
"@playwright/test": ["@playwright/test@1.57.0", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="],
"@poppinss/colors": ["@poppinss/colors@4.1.5", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw=="],
"@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
@@ -3291,6 +3295,10 @@
"planck": ["planck@1.4.2", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-mNbhnV3g8X2rwGxzcesjmN8BDA6qfXgQxXVMkWau9MCRlQY0RLNEkyHlVp6yFy/X6qrzAXyNONCnZ1cGDLrNew=="],
"playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="],
"playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="],
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
@@ -4427,6 +4435,8 @@
"pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1768302833,
"narHash": "sha256-h5bRFy9bco+8QcK7rGoOiqMxMbmn21moTACofNLRMP4=",
"lastModified": 1768569498,
"narHash": "sha256-bB6Nt99Cj8Nu5nIUq0GLmpiErIT5KFshMQJGMZwgqUo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "61db79b0c6b838d9894923920b612048e1201926",
"rev": "be5afa0fcb31f0a96bf9ecba05a516c66fcd8114",
"type": "github"
},
"original": {
+22 -2
View File
@@ -33,17 +33,37 @@
packages = forEachSystem (
pkgs:
let
opencode = pkgs.callPackage ./nix/opencode.nix {
node_modules = pkgs.callPackage ./nix/node_modules.nix {
inherit rev;
};
opencode = pkgs.callPackage ./nix/opencode.nix {
inherit node_modules;
};
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
);
};
}
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-D1VXuKJagfq3mxh8Xs8naHoYNJUJzAM9JLJqpHcItDk=",
"aarch64-linux": "sha256-9wXcg50Sv56Wb2x5NWe15olNGE/uMiDkmGRmqPoeW1U=",
"aarch64-darwin": "sha256-i5eTTjsNAARwcw69sd6wuse2BKTUi/Vfgo4M28l+RoY=",
"x86_64-darwin": "sha256-oFtQnIzgTS2zcjkhBTnXxYqr20KXdA2I+b908piLs+c="
"x86_64-linux": "sha256-80+b7FwUy4mRWTzEjPrBWuR5Um67I1Rn4U/n/s/lBjs=",
"aarch64-linux": "sha256-xH/Grwh3b+HWsUkKN8LMcyMaMcmnIJYlgp38WJCat5E=",
"aarch64-darwin": "sha256-Izv6PE9gNaeYYfcqDwjTU/WYtD1y+j65annwvLzkMD8=",
"x86_64-darwin": "sha256-EG1Z0uAeyFiOeVsv0Sz1sa8/mdXuw/uvbYYrkFR3EAg="
}
}
+85
View File
@@ -0,0 +1,85 @@
{
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 [
builtins.readFile
builtins.fromJSON
]).nodeModules.${stdenvNoCC.hostPlatform.system},
}:
let
packageJson = lib.pipe ../packages/opencode/package.json [
builtins.readFile
builtins.fromJSON
];
in
stdenvNoCC.mkDerivation {
pname = "opencode-node_modules";
version = "${packageJson.version}-${rev}";
src = lib.fileset.toSource {
root = ../.;
fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ../.)) (
lib.fileset.unions [
../packages
../bun.lock
../package.json
../patches
../install
]
);
};
impureEnvVars = lib.fetchers.proxyImpureEnvVars ++ [
"GIT_PROXY_COMMAND"
"SOCKS_SERVER"
];
nativeBuildInputs = [
bun
];
dontConfigure = true;
buildPhase = ''
runHook preBuild
export HOME=$(mktemp -d)
export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
bun install \
--cpu="${bunCpu}" \
--os="${bunOs}" \
--frozen-lockfile \
--ignore-scripts \
--no-progress \
--linker=isolated
bun --bun ${./scripts/canonicalize-node-modules.ts}
bun --bun ${./scripts/normalize-bun-binaries.ts}
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out
find . -type d -name node_modules -exec cp -R --parents {} $out \;
runHook postInstall
'';
dontFixup = true;
outputHashAlgo = "sha256";
outputHashMode = "recursive";
outputHash = hash;
meta.platforms = [
"aarch64-linux"
"x86_64-linux"
"aarch64-darwin"
"x86_64-darwin"
];
}
+5 -78
View File
@@ -1,6 +1,7 @@
{
lib,
stdenvNoCC,
callPackage,
bun,
sysctl,
makeBinaryWrapper,
@@ -9,81 +10,12 @@
installShellFiles,
versionCheckHook,
writableTmpDirAsHomeHook,
rev ? "dirty",
node_modules ? callPackage ./node-modules.nix { },
}:
let
packageJson = lib.pipe ../packages/opencode/package.json [
builtins.readFile
builtins.fromJSON
];
in
stdenvNoCC.mkDerivation (finalAttrs: {
pname = "opencode";
version = "${packageJson.version}-${rev}";
src = lib.fileset.toSource {
root = ../.;
fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ../.)) (
lib.fileset.unions [
../packages
../bun.lock
../package.json
../patches
../install
]
);
};
node_modules = stdenvNoCC.mkDerivation {
pname = "${finalAttrs.pname}-node_modules";
inherit (finalAttrs) version src;
impureEnvVars = lib.fetchers.proxyImpureEnvVars ++ [
"GIT_PROXY_COMMAND"
"SOCKS_SERVER"
];
nativeBuildInputs = [
bun
];
dontConfigure = true;
buildPhase = ''
runHook preBuild
export HOME=$(mktemp -d)
export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
bun install \
--cpu="${if stdenvNoCC.hostPlatform.isAarch64 then "arm64" else "x64"}" \
--os="${if stdenvNoCC.hostPlatform.isLinux then "linux" else "darwin"}" \
--frozen-lockfile \
--ignore-scripts \
--no-progress \
--linker=isolated
bun --bun ${./scripts/canonicalize-node-modules.ts}
bun --bun ${./scripts/normalize-bun-binaries.ts}
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out
find . -type d -name node_modules -exec cp -R --parents {} $out \;
runHook postInstall
'';
dontFixup = true;
outputHashAlgo = "sha256";
outputHashMode = "recursive";
outputHash =
(lib.pipe ./hashes.json [
builtins.readFile
builtins.fromJSON
]).nodeModules.${stdenvNoCC.hostPlatform.system};
};
inherit (node_modules) version src;
inherit node_modules;
nativeBuildInputs = [
bun
@@ -159,11 +91,6 @@ stdenvNoCC.mkDerivation (finalAttrs: {
homepage = "https://opencode.ai/";
license = lib.licenses.mit;
mainProgram = "opencode";
platforms = [
"aarch64-linux"
"x86_64-linux"
"aarch64-darwin"
"x86_64-darwin"
];
inherit (node_modules.meta) platforms;
};
})
+1
View File
@@ -44,6 +44,7 @@
"luxon": "3.6.1",
"marked": "17.0.1",
"marked-shiki": "1.2.1",
"@playwright/test": "1.51.0",
"typescript": "5.8.2",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"zod": "4.1.8",
+2
View File
@@ -1 +1,3 @@
src/assets/theme.css
e2e/test-results
e2e/playwright-report
+15
View File
@@ -29,6 +29,21 @@ It correctly bundles Solid in production mode and optimizes the build for the be
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
## E2E Testing
The Playwright runner expects the app already running at `http://localhost:3000`.
```bash
bun add -D @playwright/test
bunx playwright install
bun run test:e2e
```
Environment options:
- `PLAYWRIGHT_BASE_URL` (default: `http://localhost:3000`)
- `PLAYWRIGHT_PORT` (default: `3000`)
## Deployment
You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)
+45
View File
@@ -0,0 +1,45 @@
import { test, expect } from "./fixtures"
import { promptSelector } from "./utils"
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 sdk.session.promptAsync({
sessionID,
noReply: true,
parts: [
{
type: "text",
text: "seed context",
},
],
})
await expect
.poll(async () => {
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
return messages.length
})
.toBeGreaterThan(0)
await gotoSession(sessionID)
const contextButton = page
.locator('[data-component="button"]')
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
.first()
await expect(contextButton).toBeVisible()
await contextButton.click()
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)
}
})
+23
View File
@@ -0,0 +1,23 @@
import { test, expect } from "./fixtures"
import { modKey } from "./utils"
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 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 expect(dialog).toHaveCount(0)
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
await expect(tabs.locator('[data-slot="tabs-trigger"]').first()).toBeVisible()
})
+40
View File
@@ -0,0 +1,40 @@
import { test as base, expect } from "@playwright/test"
import { createSdk, dirSlug, getWorktree, promptSelector, sessionPath } from "./utils"
type TestFixtures = {
sdk: ReturnType<typeof createSdk>
gotoSession: (sessionID?: string) => Promise<void>
}
type WorkerFixtures = {
directory: string
slug: string
}
export const test = base.extend<TestFixtures, WorkerFixtures>({
directory: [
async ({}, use) => {
const directory = await getWorktree()
await use(directory)
},
{ scope: "worker" },
],
slug: [
async ({ directory }, use) => {
await use(dirSlug(directory))
},
{ scope: "worker" },
],
sdk: async ({ directory }, use) => {
await use(createSdk(directory))
},
gotoSession: async ({ page, directory }, use) => {
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible()
}
await use(gotoSession)
},
})
export { expect }
+21
View File
@@ -0,0 +1,21 @@
import { test, expect } from "./fixtures"
import { serverName } from "./utils"
test("home renders and shows core entrypoints", async ({ page }) => {
await page.goto("/")
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
await expect(page.getByRole("button", { name: serverName })).toBeVisible()
})
test("server picker dialog opens from home", async ({ page }) => {
await page.goto("/")
const trigger = page.getByRole("button", { name: serverName })
await expect(trigger).toBeVisible()
await trigger.click()
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("textbox").first()).toBeVisible()
})
+9
View File
@@ -0,0 +1,9 @@
import { test, expect } from "./fixtures"
import { dirPath, promptSelector } from "./utils"
test("project route redirects to /session", async ({ page, directory, slug }) => {
await page.goto(dirPath(directory))
await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
await expect(page.locator(promptSelector)).toBeVisible()
})
+15
View File
@@ -0,0 +1,15 @@
import { test, expect } from "./fixtures"
import { modKey } from "./utils"
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()
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})
+21
View File
@@ -0,0 +1,21 @@
import { test, expect } from "./fixtures"
import { promptSelector } from "./utils"
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)
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)
}
})
+21
View File
@@ -0,0 +1,21 @@
import { test, expect } from "./fixtures"
import { modKey } from "./utils"
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"))
if (isClosed) {
await page.keyboard.press(`${modKey}+B`)
await expect(main).not.toHaveClass(closedClass)
}
await page.keyboard.press(`${modKey}+B`)
await expect(main).toHaveClass(closedClass)
await page.keyboard.press(`${modKey}+B`)
await expect(main).not.toHaveClass(closedClass)
})
+16
View File
@@ -0,0 +1,16 @@
import { test, expect } from "./fixtures"
import { terminalSelector, terminalToggleKey } from "./utils"
test("terminal panel can be toggled", async ({ page, gotoSession }) => {
await gotoSession()
const terminal = page.locator(terminalSelector)
const initiallyOpen = await terminal.isVisible()
if (initiallyOpen) {
await page.keyboard.press(terminalToggleKey)
await expect(terminal).toHaveCount(0)
}
await page.keyboard.press(terminalToggleKey)
await expect(terminal).toBeVisible()
})
+38
View File
@@ -0,0 +1,38 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/util/encode"
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
export const serverUrl = `http://${serverHost}:${serverPort}`
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 function createSdk(directory?: string) {
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
}
export async function getWorktree() {
const sdk = createSdk()
const result = await sdk.path.get()
const data = result.data
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${serverUrl}/path`)
return data.worktree
}
export function dirSlug(directory: string) {
return base64Encode(directory)
}
export function dirPath(directory: string) {
return `/${dirSlug(directory)}`
}
export function sessionPath(directory: string, sessionID?: string) {
return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}`
}
+4 -4
View File
@@ -4,10 +4,10 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenCode</title>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" href="/favicon-96x96-v2.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon-v2.svg" />
<link rel="shortcut icon" href="/favicon-v2.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v2.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#F8F7F7" />
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
+7 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.25",
"version": "1.1.26",
"description": "",
"type": "module",
"exports": {
@@ -12,11 +12,16 @@
"start": "vite",
"dev": "vite",
"build": "vite build",
"serve": "vite preview"
"serve": "vite preview",
"test": "playwright test",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report e2e/playwright-report"
},
"license": "MIT",
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "1.57.0",
"@tailwindcss/vite": "catalog:",
"@tsconfig/bun": "1.0.9",
"@types/bun": "catalog:",
+43
View File
@@ -0,0 +1,43 @@
import { defineConfig, devices } from "@playwright/test"
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${port}`
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
export default defineConfig({
testDir: "./e2e",
outputDir: "./e2e/test-results",
timeout: 60_000,
expect: {
timeout: 10_000,
},
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
webServer: {
command,
url: baseURL,
reuseExistingServer: reuse,
timeout: 120_000,
env: {
VITE_OPENCODE_SERVER_HOST: serverHost,
VITE_OPENCODE_SERVER_PORT: serverPort,
},
},
use: {
baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
})
+1
View File
@@ -0,0 +1 @@
../../ui/src/assets/favicon/apple-touch-icon-v2.png
+1
View File
@@ -0,0 +1 @@
../../ui/src/assets/favicon/favicon-96x96-v2.png
+1
View File
@@ -0,0 +1 @@
../../ui/src/assets/favicon/favicon-v2.ico
+1
View File
@@ -0,0 +1 @@
../../ui/src/assets/favicon/favicon-v2.svg
@@ -22,16 +22,20 @@ export function DialogEditProject(props: { project: LocalProject }) {
const [store, setStore] = createStore({
name: defaultName(),
color: props.project.icon?.color || "pink",
iconUrl: props.project.icon?.url || "",
iconUrl: props.project.icon?.override || "",
saving: false,
})
const [dragOver, setDragOver] = createSignal(false)
const [iconHover, setIconHover] = createSignal(false)
function handleFileSelect(file: File) {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (e) => setStore("iconUrl", e.target?.result as string)
reader.onload = (e) => {
setStore("iconUrl", e.target?.result as string)
setIconHover(false)
}
reader.readAsDataURL(file)
}
@@ -70,15 +74,15 @@ export function DialogEditProject(props: { project: LocalProject }) {
await globalSDK.client.project.update({
projectID: props.project.id,
name,
icon: { color: store.color, url: store.iconUrl },
icon: { color: store.color, override: store.iconUrl },
})
setStore("saving", false)
dialog.close()
}
return (
<Dialog title="Edit project">
<form onSubmit={handleSubmit} class="flex flex-col gap-6 px-2.5 pb-3">
<Dialog title="Edit project" class="w-full max-w-[480px] mx-auto">
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6">
<div class="flex flex-col gap-4">
<TextField
autofocus
@@ -92,17 +96,24 @@ export function DialogEditProject(props: { project: LocalProject }) {
<div class="flex flex-col gap-2">
<label class="text-12-medium text-text-weak">Icon</label>
<div class="flex gap-3 items-start">
<div class="relative">
<div class="relative" onMouseEnter={() => setIconHover(true)} onMouseLeave={() => setIconHover(false)}>
<div
class="size-16 rounded-lg overflow-hidden border border-dashed transition-colors cursor-pointer"
class="relative size-16 rounded-md transition-colors cursor-pointer"
classList={{
"border-text-interactive-base bg-surface-info-base/20": dragOver(),
"border-border-base hover:border-border-strong": !dragOver(),
"overflow-hidden": !!store.iconUrl,
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => document.getElementById("icon-upload")?.click()}
onClick={() => {
if (store.iconUrl && iconHover()) {
clearIcon()
} else {
document.getElementById("icon-upload")?.click()
}
}}
>
<Show
when={store.iconUrl}
@@ -119,20 +130,48 @@ export function DialogEditProject(props: { project: LocalProject }) {
<img src={store.iconUrl} alt="Project icon" class="size-full object-cover" />
</Show>
</div>
<Show when={store.iconUrl}>
<button
type="button"
class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-base border border-border-base flex items-center justify-center hover:bg-surface-raised-base-hover"
onClick={clearIcon}
>
<Icon name="close" class="size-3 text-icon-base" />
</button>
</Show>
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "64px",
height: "64px",
background: "rgba(0,0,0,0.6)",
"border-radius": "6px",
"z-index": 10,
"pointer-events": "none",
opacity: iconHover() && !store.iconUrl ? 1 : 0,
display: "flex",
"align-items": "center",
"justify-content": "center",
}}
>
<Icon name="cloud-upload" size="large" class="text-icon-invert-base" />
</div>
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "64px",
height: "64px",
background: "rgba(0,0,0,0.6)",
"border-radius": "6px",
"z-index": 10,
"pointer-events": "none",
opacity: iconHover() && store.iconUrl ? 1 : 0,
display: "flex",
"align-items": "center",
"justify-content": "center",
}}
>
<Icon name="trash" size="large" class="text-icon-invert-base" />
</div>
</div>
<input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak">
<span>Click or drag an image</span>
<span>Recommended: 128x128px</span>
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
<span>Recommended size 128x128px</span>
</div>
</div>
</div>
@@ -140,20 +179,25 @@ export function DialogEditProject(props: { project: LocalProject }) {
<Show when={!store.iconUrl}>
<div class="flex flex-col gap-2">
<label class="text-12-medium text-text-weak">Color</label>
<div class="flex gap-2">
<div class="flex gap-1.5">
<For each={AVATAR_COLOR_KEYS}>
{(color) => (
<button
type="button"
class="relative size-8 rounded-md transition-all"
classList={{
"ring-2 ring-offset-2 ring-offset-surface-base ring-text-interactive-base":
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
store.color === color,
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
store.color !== color,
}}
style={{ background: getAvatarColors(color).background }}
onClick={() => setStore("color", color)}
>
<Avatar fallback={store.name || defaultName()} {...getAvatarColors(color)} class="size-full" />
<Avatar
fallback={store.name || defaultName()}
{...getAvatarColors(color)}
class="size-full rounded"
/>
</button>
)}
</For>
+21 -2
View File
@@ -300,7 +300,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
event.stopPropagation()
const items = Array.from(clipboardData.items)
const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
const fileItems = items.filter((item) => item.kind === "file")
const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
if (imageItems.length > 0) {
for (const item of imageItems) {
@@ -310,7 +311,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
if (fileItems.length > 0) {
showToast({
title: "Unsupported paste",
description: "Only images or PDFs can be pasted here.",
})
return
}
const plainText = clipboardData.getData("text/plain") ?? ""
if (!plainText) return
addPart({ type: "text", content: plainText, start: 0, end: 0 })
}
@@ -1056,7 +1066,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let session = info()
if (!session && isNewSession) {
session = await client.session.create().then((x) => x.data ?? undefined)
session = await client.session
.create()
.then((x) => x.data ?? undefined)
.catch((err) => {
showToast({
title: "Failed to create session",
description: errorMessage(err),
})
return undefined
})
if (session) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
}
if (!session) return
@@ -244,7 +244,11 @@ export function SessionHeader() {
}
trigger={
<Tooltip class="shrink-0" value="Share session">
<Button variant="secondary" classList={{ "rounded-r-none": shareUrl() !== undefined }}>
<Button
variant="secondary"
classList={{ "rounded-r-none": shareUrl() !== undefined }}
style={{ scale: 1 }}
>
Share
</Button>
</Tooltip>
@@ -293,12 +297,12 @@ export function SessionHeader() {
</Show>
</div>
</Popover>
<Show when={shareUrl()}>
<Show when={shareUrl()} fallback={<div class="size-6" aria-hidden="true" />}>
<Tooltip value={state.copied ? "Copied" : "Copy link"} placement="top" gutter={8}>
<IconButton
icon={state.copied ? "check" : "copy"}
variant="secondary"
class="rounded-l-none border-l border-border-weak-base"
class="rounded-l-none"
onClick={copyLink}
disabled={state.unshare}
/>
+54 -36
View File
@@ -33,8 +33,6 @@ type SessionTabs = {
type SessionView = {
scroll: Record<string, SessionScroll>
reviewOpen?: string[]
terminalOpened?: boolean
reviewPanelOpened?: boolean
}
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
@@ -78,9 +76,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
terminal: {
height: 280,
opened: false,
},
review: {
diffStyle: "split" as ReviewDiffStyle,
panelOpened: true,
},
session: {
width: 600,
@@ -172,7 +172,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const current = store.sessionView[sessionKey]
const keep = meta.active ?? sessionKey
if (!current) {
setStore("sessionView", sessionKey, { scroll: next, terminalOpened: false, reviewPanelOpened: true })
setStore("sessionView", sessionKey, { scroll: next })
prune(keep)
return
}
@@ -208,10 +208,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
})
})
const usedColors = new Set<AvatarColorKey>()
const [colors, setColors] = createStore<Record<string, AvatarColorKey>>({})
function pickAvailableColor(): AvatarColorKey {
const available = AVATAR_COLOR_KEYS.filter((c) => !usedColors.has(c))
function pickAvailableColor(used: Set<string>): AvatarColorKey {
const available = AVATAR_COLOR_KEYS.filter((c) => !used.has(c))
if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)]
return available[Math.floor(Math.random() * available.length)]
}
@@ -222,24 +222,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const metadata = projectID
? globalSync.data.project.find((x) => x.id === projectID)
: globalSync.data.project.find((x) => x.worktree === project.worktree)
return [
{
...(metadata ?? {}),
...project,
icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
return {
...(metadata ?? {}),
...project,
icon: {
url: metadata?.icon?.url,
override: metadata?.icon?.override,
color: metadata?.icon?.color,
},
]
}
function colorize(project: LocalProject) {
if (project.icon?.color) return project
const color = pickAvailableColor()
usedColors.add(color)
project.icon = { ...project.icon, color }
if (project.id) {
globalSdk.client.project.update({ projectID: project.id, icon: { color } })
}
return project
}
const roots = createMemo(() => {
@@ -277,8 +268,37 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
})
})
const enriched = createMemo(() => server.projects.list().flatMap(enrich))
const list = createMemo(() => enriched().flatMap(colorize))
const enriched = createMemo(() => server.projects.list().map(enrich))
const list = createMemo(() => {
const projects = enriched()
return projects.map((project) => {
const color = project.icon?.color ?? colors[project.worktree]
if (!color) return project
const icon = project.icon ? { ...project.icon, color } : { color }
return { ...project, icon }
})
})
createEffect(() => {
const projects = enriched()
if (projects.length === 0) return
const used = new Set<string>()
for (const project of projects) {
const color = project.icon?.color ?? colors[project.worktree]
if (color) used.add(color)
}
for (const project of projects) {
if (project.icon?.color) continue
if (colors[project.worktree]) continue
const color = pickAvailableColor(used)
used.add(color)
setColors(project.worktree, color)
if (!project.id) continue
void globalSdk.client.project.update({ projectID: project.id, icon: { color } })
}
})
onMount(() => {
Promise.all(
@@ -379,31 +399,31 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
touch(sessionKey)
scroll.seed(sessionKey)
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
const terminalOpened = createMemo(() => s().terminalOpened ?? false)
const reviewPanelOpened = createMemo(() => s().reviewPanelOpened ?? true)
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
function setTerminalOpened(next: boolean) {
const current = store.sessionView[sessionKey]
const current = store.terminal
if (!current) {
setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: next, reviewPanelOpened: true })
setStore("terminal", { height: 280, opened: next })
return
}
const value = current.terminalOpened ?? false
const value = current.opened ?? false
if (value === next) return
setStore("sessionView", sessionKey, "terminalOpened", next)
setStore("terminal", "opened", next)
}
function setReviewPanelOpened(next: boolean) {
const current = store.sessionView[sessionKey]
const current = store.review
if (!current) {
setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: false, reviewPanelOpened: next })
setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next })
return
}
const value = current.reviewPanelOpened ?? true
const value = current.panelOpened ?? true
if (value === next) return
setStore("sessionView", sessionKey, "reviewPanelOpened", next)
setStore("review", "panelOpened", next)
}
return {
@@ -444,8 +464,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (!current) {
setStore("sessionView", sessionKey, {
scroll: {},
terminalOpened: false,
reviewPanelOpened: true,
reviewOpen: open,
})
return
+34 -23
View File
@@ -25,11 +25,11 @@ type TerminalCacheEntry = {
dispose: VoidFunction
}
function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id: string | undefined) {
const legacy = `${dir}/terminal${id ? "/" + id : ""}.v1`
function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, session?: string) {
const legacy = session ? [`${dir}/terminal/${session}.v1`, `${dir}/terminal.v1`] : [`${dir}/terminal.v1`]
const [store, setStore, _, ready] = persisted(
Persist.scoped(dir, id, "terminal", [legacy]),
Persist.workspace(dir, "terminal", legacy),
createStore<{
active?: string
all: LocalPTY[]
@@ -43,17 +43,28 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id:
all: createMemo(() => Object.values(store.all)),
active: createMemo(() => store.active),
new() {
const parse = (title: string) => {
const match = title.match(/^Terminal (\d+)$/)
if (!match) return
const value = Number(match[1])
if (!Number.isFinite(value) || value <= 0) return
return value
}
const existingTitleNumbers = new Set(
store.all.map((pty) => {
const match = pty.titleNumber
return match
store.all.flatMap((pty) => {
const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
if (direct !== undefined) return [direct]
const parsed = parse(pty.title)
if (parsed === undefined) return []
return [parsed]
}),
)
let nextNumber = 1
while (existingTitleNumbers.has(nextNumber)) {
nextNumber++
}
const nextNumber =
Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find(
(number) => !existingTitleNumbers.has(number),
) ?? 1
sdk.client.pty
.create({ title: `Terminal ${nextNumber}` })
@@ -166,8 +177,8 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
}
const load = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}`
const load = (dir: string, session?: string) => {
const key = `${dir}:${WORKSPACE_KEY}`
const existing = cache.get(key)
if (existing) {
cache.delete(key)
@@ -176,7 +187,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
const entry = createRoot((dispose) => ({
value: createTerminalSession(sdk, dir, id),
value: createTerminalSession(sdk, dir, session),
dispose,
}))
@@ -185,18 +196,18 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value
}
const session = createMemo(() => load(params.dir!, params.id))
const workspace = createMemo(() => load(params.dir!, params.id))
return {
ready: () => session().ready(),
all: () => session().all(),
active: () => session().active(),
new: () => session().new(),
update: (pty: Partial<LocalPTY> & { id: string }) => session().update(pty),
clone: (id: string) => session().clone(id),
open: (id: string) => session().open(id),
close: (id: string) => session().close(id),
move: (id: string, to: number) => session().move(id, to),
ready: () => workspace().ready(),
all: () => workspace().all(),
active: () => workspace().active(),
new: () => workspace().new(),
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
clone: (id: string) => workspace().clone(id),
open: (id: string) => workspace().open(id),
close: (id: string) => workspace().close(id),
move: (id: string, to: number) => workspace().move(id, to),
}
},
})
+1 -1
View File
@@ -37,7 +37,7 @@ const platform: Platform = {
.then(() => {
const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96.png",
icon: "https://opencode.ai/favicon-96x96-v2.png",
})
notification.onclick = () => {
window.focus()
+450 -120
View File
@@ -28,12 +28,14 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { HoverCard } from "@opencode-ai/ui/hover-card"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Dialog } from "@opencode-ai/ui/dialog"
import { getFilename } from "@opencode-ai/util/path"
import { Session } from "@opencode-ai/sdk/v2/client"
import { Session, type Message, type TextPart } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform"
import { createStore, produce, reconcile } from "solid-js/store"
import {
@@ -399,7 +401,24 @@ export default function Layout(props: ParentProps) {
const currentProject = createMemo(() => {
const directory = params.dir ? base64Decode(params.dir) : undefined
if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
const projects = layout.projects.list()
const sandbox = projects.find((p) => p.sandboxes?.includes(directory))
if (sandbox) return sandbox
const direct = projects.find((p) => p.worktree === directory)
if (direct) return direct
const [child] = globalSync.child(directory)
const id = child.project
if (!id) return
const meta = globalSync.data.project.find((p) => p.id === id)
const root = meta?.worktree
if (!root) return
return projects.find((p) => p.worktree === root)
})
createEffect(
@@ -521,7 +540,7 @@ export default function Layout(props: ParentProps) {
running: number
}
const prefetchChunk = 200
const prefetchChunk = 600
const prefetchConcurrency = 1
const prefetchPendingLimit = 6
const prefetchToken = { value: 0 }
@@ -906,6 +925,241 @@ export default function Layout(props: ParentProps) {
}
}
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return "Request failed"
}
const deleteWorkspace = async (directory: string) => {
const current = currentProject()
if (!current) return
if (directory === current.worktree) return
const result = await globalSDK.client.worktree
.remove({ directory: current.worktree, worktreeRemoveInput: { directory } })
.then((x) => x.data)
.catch((err) => {
showToast({
title: "Failed to delete workspace",
description: errorMessage(err),
})
return false
})
if (!result) return
layout.projects.close(directory)
layout.projects.open(current.worktree)
if (params.dir && base64Decode(params.dir) === directory) {
navigateToProject(current.worktree)
}
}
const resetWorkspace = async (directory: string) => {
const current = currentProject()
if (!current) return
if (directory === current.worktree) return
const progress = showToast({
persistent: true,
title: "Resetting workspace",
description: "This may take a minute.",
})
const dismiss = () => toaster.dismiss(progress)
const sessions = await globalSDK.client.session
.list({ directory })
.then((x) => x.data ?? [])
.catch(() => [])
const result = await globalSDK.client.worktree
.reset({ directory: current.worktree, worktreeResetInput: { directory } })
.then((x) => x.data)
.catch((err) => {
dismiss()
showToast({
title: "Failed to reset workspace",
description: errorMessage(err),
})
return false
})
if (!result) {
dismiss()
return
}
const archivedAt = Date.now()
await Promise.all(
sessions
.filter((session) => session.time.archived === undefined)
.map((session) =>
globalSDK.client.session
.update({
sessionID: session.id,
directory: session.directory,
time: { archived: archivedAt },
})
.catch(() => undefined),
),
)
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
dismiss()
const href = `/${base64Encode(directory)}/session`
navigate(href)
layout.mobileSidebar.hide()
showToast({
title: "Workspace reset",
description: "Workspace now matches the default branch.",
})
}
function DialogDeleteWorkspace(props: { directory: string }) {
const name = createMemo(() => getFilename(props.directory))
const [data, setData] = createStore({
status: "loading" as "loading" | "ready" | "error",
dirty: false,
})
onMount(() => {
const current = currentProject()
if (!current) {
setData({ status: "error", dirty: false })
return
}
globalSDK.client.file
.status({ directory: props.directory })
.then((x) => {
const files = x.data ?? []
const dirty = files.length > 0
setData({ status: "ready", dirty })
})
.catch(() => {
setData({ status: "error", dirty: false })
})
})
const handleDelete = async () => {
await deleteWorkspace(props.directory)
dialog.close()
}
const description = () => {
if (data.status === "loading") return "Checking for unmerged changes..."
if (data.status === "error") return "Unable to verify git status."
if (!data.dirty) return "No unmerged changes detected."
return "Unmerged changes detected in this workspace."
}
return (
<Dialog title="Delete workspace" fit>
<div class="flex flex-col gap-4 px-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">Delete workspace "{name()}"?</span>
<span class="text-12-regular text-text-weak">{description()}</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
Cancel
</Button>
<Button variant="primary" size="large" disabled={data.status === "loading"} onClick={handleDelete}>
Delete workspace
</Button>
</div>
</div>
</Dialog>
)
}
function DialogResetWorkspace(props: { directory: string }) {
const name = createMemo(() => getFilename(props.directory))
const [state, setState] = createStore({
status: "loading" as "loading" | "ready" | "error",
dirty: false,
sessions: [] as Session[],
})
const refresh = async () => {
const sessions = await globalSDK.client.session
.list({ directory: props.directory })
.then((x) => x.data ?? [])
.catch(() => [])
const active = sessions.filter((session) => session.time.archived === undefined)
setState({ sessions: active })
}
onMount(() => {
const current = currentProject()
if (!current) {
setState({ status: "error", dirty: false })
return
}
globalSDK.client.file
.status({ directory: props.directory })
.then((x) => {
const files = x.data ?? []
const dirty = files.length > 0
setState({ status: "ready", dirty })
void refresh()
})
.catch(() => {
setState({ status: "error", dirty: false })
})
})
const handleReset = () => {
dialog.close()
void resetWorkspace(props.directory)
}
const archivedCount = () => state.sessions.length
const description = () => {
if (state.status === "loading") return "Checking for unmerged changes..."
if (state.status === "error") return "Unable to verify git status."
if (!state.dirty) return "No unmerged changes detected."
return "Unmerged changes detected in this workspace."
}
const archivedLabel = () => {
const count = archivedCount()
if (count === 0) return "No active sessions will be archived."
const label = count === 1 ? "1 session" : `${count} sessions`
return `${label} will be archived.`
}
return (
<Dialog title="Reset workspace" fit>
<div class="flex flex-col gap-4 px-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">Reset workspace "{name()}"?</span>
<span class="text-12-regular text-text-weak">
{description()} {archivedLabel()} This will reset the workspace to match the default branch.
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
Cancel
</Button>
<Button variant="primary" size="large" disabled={state.status === "loading"} onClick={handleReset}>
Reset workspace
</Button>
</div>
</div>
</Dialog>
)
}
createEffect(
on(
() => ({ ready: pageReady(), dir: params.dir, id: params.id }),
@@ -975,11 +1229,15 @@ export default function Layout(props: ParentProps) {
function workspaceIds(project: LocalProject | undefined) {
if (!project) return []
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
const existing = store.workspaceOrder[project.worktree]
if (!existing) return dirs
const active = currentProject()
const directory = active?.worktree === project.worktree && params.dir ? base64Decode(params.dir) : undefined
const next = directory && directory !== project.worktree && !dirs.includes(directory) ? [...dirs, directory] : dirs
const keep = existing.filter((d) => dirs.includes(d))
const missing = dirs.filter((d) => !existing.includes(d))
const existing = store.workspaceOrder[project.worktree]
if (!existing) return next
const keep = existing.filter((d) => next.includes(d))
const missing = next.filter((d) => !existing.includes(d))
return [...keep, ...missing]
}
@@ -1026,7 +1284,7 @@ export default function Layout(props: ParentProps) {
<div class="size-full rounded overflow-clip">
<Avatar
fallback={name()}
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.override}
{...getAvatarColors(props.project.icon?.color)}
class="size-full rounded"
style={
@@ -1049,7 +1307,13 @@ export default function Layout(props: ParentProps) {
)
}
const SessionItem = (props: { session: Session; slug: string; mobile?: boolean; dense?: boolean }): JSX.Element => {
const SessionItem = (props: {
session: Session
slug: string
mobile?: boolean
dense?: boolean
popover?: boolean
}): JSX.Element => {
const notification = useNotification()
const notifications = createMemo(() => notification.session.unseen(props.session.id))
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
@@ -1083,63 +1347,101 @@ export default function Layout(props: ParentProps) {
return agent?.color
})
const hoverMessages = createMemo(() =>
sessionStore.message[props.session.id]?.filter((message) => message.role === "user"),
)
const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
const hoverAllowed = createMemo(() => !props.mobile && layout.sidebar.opened())
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
const isActive = createMemo(() => props.session.id === params.id)
const messageLabel = (message: Message) => {
const parts = sessionStore.part[message.id] ?? []
const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
return text?.text
}
const item = (
<A
href={`${props.slug}/session/${props.session.id}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onMouseEnter={() => prefetchSession(props.session, "high")}
onFocus={() => prefetchSession(props.session, "high")}
>
<div class="flex items-center gap-1 w-full">
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={notifications().length > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<InlineEditor
id={`session:${props.session.id}`}
value={() => props.session.title}
onSave={(next) => renameSession(props.session, next)}
class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
stopPropagation
/>
<Show when={props.session.summary}>
{(summary) => (
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
<DiffChanges changes={summary()} />
</div>
)}
</Show>
</div>
</A>
)
return (
<div
data-session-id={props.session.id}
class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
>
<A
href={`${props.slug}/session/${props.session.id}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onMouseEnter={() => prefetchSession(props.session, "high")}
onFocus={() => prefetchSession(props.session, "high")}
>
<div class="flex items-center gap-1 w-full">
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={notifications().length > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<Tooltip
placement="top-start"
value={props.session.title}
gutter={0}
openDelay={3000}
class="grow-1 min-w-0"
>
<InlineEditor
id={`session:${props.session.id}`}
value={() => props.session.title}
onSave={(next) => renameSession(props.session, next)}
class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
stopPropagation
/>
<Show
when={hoverEnabled()}
fallback={
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
{item}
</Tooltip>
<Show when={props.session.summary}>
{(summary) => (
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
<DiffChanges changes={summary()} />
</div>
)}
}
>
<HoverCard openDelay={150} closeDelay={100} placement="right" gutter={12} trigger={item}>
<Show when={hoverReady()} fallback={<div class="text-12-regular text-text-weak">Loading messages</div>}>
<MessageNav
messages={hoverMessages() ?? []}
current={undefined}
getLabel={messageLabel}
onMessageSelect={(message) => {
if (!isActive()) {
sessionStorage.setItem("opencode.pendingMessage", `${props.session.id}|${message.id}`)
navigate(`${props.slug}/session/${props.session.id}`)
return
}
window.history.replaceState(null, "", `#message-${message.id}`)
window.dispatchEvent(new HashChangeEvent("hashchange"))
}}
size="normal"
class="w-60"
/>
</Show>
</div>
</A>
</HoverCard>
</Show>
<div
class={`hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"}`}
>
@@ -1205,6 +1507,7 @@ export default function Layout(props: ParentProps) {
const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => {
const sortable = createSortable(props.directory)
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory)
const [menuOpen, setMenuOpen] = createSignal(false)
const slug = createMemo(() => base64Encode(props.directory))
const sessions = createMemo(() =>
workspaceStore.session
@@ -1239,62 +1542,91 @@ export default function Layout(props: ParentProps) {
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
<div class="px-2 py-1">
<div class="group/trigger relative">
<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">
<div class="flex items-center gap-1 min-w-0 flex-1">
<div class="flex items-center justify-center shrink-0 size-6">
<Icon name="branch" size="small" />
</div>
<span class="text-14-medium text-text-base shrink-0">{local() ? "local" : "sandbox"} :</span>
<Show
when={!local()}
fallback={
<span class="text-14-medium text-text-base min-w-0 truncate">
{workspaceStore.vcs?.branch ?? getFilename(props.directory)}
</span>
}
>
<InlineEditor
id={`workspace:${props.directory}`}
value={workspaceValue}
onSave={(next) => {
const trimmed = next.trim()
if (!trimmed) return
renameWorkspace(props.directory, trimmed)
setEditor("value", workspaceValue())
}}
class="text-14-medium text-text-base min-w-0 truncate"
displayClass="text-14-medium text-text-base min-w-0 truncate"
editing={workspaceEditActive()}
stopPropagation={false}
openOnDblClick={false}
<div class="group/workspace relative">
<div class="flex items-center gap-1">
<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">
<div class="flex items-center gap-1 min-w-0 flex-1">
<div class="flex items-center justify-center shrink-0 size-6">
<Icon name="branch" size="small" />
</div>
<span class="text-14-medium text-text-base shrink-0">{local() ? "local" : "sandbox"} :</span>
<Show
when={!local()}
fallback={
<span class="text-14-medium text-text-base min-w-0 truncate">
{workspaceStore.vcs?.branch ?? getFilename(props.directory)}
</span>
}
>
<InlineEditor
id={`workspace:${props.directory}`}
value={workspaceValue}
onSave={(next) => {
const trimmed = next.trim()
if (!trimmed) return
renameWorkspace(props.directory, trimmed)
setEditor("value", workspaceValue())
}}
class="text-14-medium text-text-base min-w-0 truncate"
displayClass="text-14-medium text-text-base min-w-0 truncate"
editing={workspaceEditActive()}
stopPropagation={false}
openOnDblClick={false}
/>
</Show>
<Icon
name={open() ? "chevron-down" : "chevron-right"}
size="small"
class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/workspace:opacity-100 group-focus-within/workspace:opacity-100"
/>
</Show>
<Icon
name={open() ? "chevron-down" : "chevron-right"}
size="small"
class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/trigger:opacity-100 group-focus-within/trigger:opacity-100"
/>
</div>
</Collapsible.Trigger>
<div class="absolute right-1 top-1/2 -translate-y-1/2 hidden items-center gap-0.5 pointer-events-none group-hover/trigger:flex group-focus-within/trigger:flex">
<IconButton icon="dot-grid" variant="ghost" class="size-6 rounded-md pointer-events-auto" />
<TooltipKeybind
class="pointer-events-auto"
placement="right"
title="New session"
keybind={command.keybind("session.new")}
</div>
</Collapsible.Trigger>
<div
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
classList={{
"opacity-100 pointer-events-auto": menuOpen(),
"opacity-0 pointer-events-none": !menuOpen(),
"group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true,
"group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true,
}}
>
<IconButton
icon="plus-small"
variant="ghost"
class="size-6 rounded-md"
onClick={() => navigate(`/${slug()}/session`)}
/>
</TooltipKeybind>
<DropdownMenu open={menuOpen()} onOpenChange={setMenuOpen}>
<Tooltip value="More options" placement="top">
<DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" class="size-6 rounded-md" />
</Tooltip>
<DropdownMenu.Portal>
<DropdownMenu.Content>
<DropdownMenu.Item onSelect={() => navigate(`/${slug()}/session`)}>
<DropdownMenu.ItemLabel>New session</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
disabled={local()}
onSelect={() => dialog.show(() => <DialogResetWorkspace directory={props.directory} />)}
>
<DropdownMenu.ItemLabel>Reset workspace</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
disabled={local()}
onSelect={() => dialog.show(() => <DialogDeleteWorkspace directory={props.directory} />)}
>
<DropdownMenu.ItemLabel>Delete workspace</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<TooltipKeybind placement="right" title="New session" keybind={command.keybind("session.new")}>
<IconButton
icon="plus-small"
variant="ghost"
class="size-6 rounded-md"
onClick={() => navigate(`/${slug()}/session`)}
/>
</TooltipKeybind>
</div>
</div>
</div>
</div>
<Collapsible.Content>
<nav class="flex flex-col gap-1 px-2">
<Button
@@ -1412,6 +1744,7 @@ export default function Layout(props: ParentProps) {
slug={base64Encode(props.project.worktree)}
dense
mobile={props.mobile}
popover={false}
/>
)}
</For>
@@ -1428,7 +1761,13 @@ export default function Layout(props: ParentProps) {
</div>
<For each={sessions(directory)}>
{(session) => (
<SessionItem session={session} slug={base64Encode(directory)} dense mobile={props.mobile} />
<SessionItem
session={session}
slug={base64Encode(directory)}
dense
mobile={props.mobile}
popover={false}
/>
)}
</For>
</div>
@@ -1520,15 +1859,6 @@ export default function Layout(props: ParentProps) {
const projectId = createMemo(() => project()?.id ?? "")
const workspaces = createMemo(() => workspaceIds(project()))
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return "Request failed"
}
const createWorkspace = async () => {
const current = project()
if (!current) return
@@ -1590,7 +1920,7 @@ export default function Layout(props: ParentProps) {
</DragDropProvider>
</div>
<div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2">
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Settings">
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Settings" class="hidden">
<IconButton disabled icon="settings-gear" variant="ghost" size="large" />
</Tooltip>
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Help">
+204 -98
View File
@@ -1,4 +1,4 @@
import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on, createSignal } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web"
@@ -18,7 +18,7 @@ import { useCodeComponent } from "@opencode-ai/ui/context/code"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { SessionReview } from "@opencode-ai/ui/session-review"
import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
@@ -167,6 +167,7 @@ export default function Page() {
const sdk = useSDK()
const prompt = usePrompt()
const permission = usePermission()
const [pendingMessage, setPendingMessage] = createSignal<string | undefined>(undefined)
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
const view = createMemo(() => layout.view(sessionKey()))
@@ -530,7 +531,7 @@ export default function Page() {
title: "Cycle thinking effort",
description: "Switch to the next effort level",
category: "Model",
keybind: "shift+mod+t",
keybind: "shift+mod+d",
onSelect: () => {
local.model.variant.cycle()
},
@@ -788,17 +789,14 @@ export default function Page() {
.filter((tab) => tab !== "context"),
)
const reviewTab = createMemo(() => hasReview() || tabs().active() === "review")
const mobileReview = createMemo(() => !isDesktop() && hasReview() && store.mobileTab === "review")
const mobileReview = createMemo(() => !isDesktop() && view().reviewPanel.opened() && store.mobileTab === "review")
const showTabs = createMemo(
() => view().reviewPanel.opened() && (hasReview() || tabs().all().length > 0 || contextOpen()),
)
const showTabs = createMemo(() => view().reviewPanel.opened())
const activeTab = createMemo(() => {
const active = tabs().active()
if (active) return active
if (reviewTab()) return "review"
if (hasReview()) return "review"
const first = openedTabs()[0]
if (first) return first
@@ -946,17 +944,30 @@ export default function Page() {
window.history.replaceState(null, "", `#${anchor(id)}`)
}
createEffect(() => {
const sessionID = params.id
if (!sessionID) return
const raw = sessionStorage.getItem("opencode.pendingMessage")
if (!raw) return
const parts = raw.split("|")
const pendingSessionID = parts[0]
const messageID = parts[1]
if (!pendingSessionID || !messageID) return
if (pendingSessionID !== sessionID) return
sessionStorage.removeItem("opencode.pendingMessage")
setPendingMessage(messageID)
})
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
const root = scroller
if (!root) {
el.scrollIntoView({ behavior, block: "start" })
return
}
if (!root) return false
const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect()
const top = a.top - b.top + root.scrollTop
root.scrollTo({ top, behavior })
return true
}
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
@@ -970,7 +981,15 @@ export default function Page() {
requestAnimationFrame(() => {
const el = document.getElementById(anchor(message.id))
if (el) scrollToElement(el, behavior)
if (!el) {
requestAnimationFrame(() => {
const next = document.getElementById(anchor(message.id))
if (!next) return
scrollToElement(next, behavior)
})
return
}
scrollToElement(el, behavior)
})
updateHash(message.id)
@@ -978,10 +997,57 @@ export default function Page() {
}
const el = document.getElementById(anchor(message.id))
if (el) scrollToElement(el, behavior)
if (!el) {
updateHash(message.id)
requestAnimationFrame(() => {
const next = document.getElementById(anchor(message.id))
if (!next) return
if (!scrollToElement(next, behavior)) return
})
return
}
if (scrollToElement(el, behavior)) {
updateHash(message.id)
return
}
requestAnimationFrame(() => {
const next = document.getElementById(anchor(message.id))
if (!next) return
if (!scrollToElement(next, behavior)) return
})
updateHash(message.id)
}
const applyHash = (behavior: ScrollBehavior) => {
const hash = window.location.hash.slice(1)
if (!hash) {
autoScroll.forceScrollToBottom()
return
}
const match = hash.match(/^message-(.+)$/)
if (match) {
const msg = visibleUserMessages().find((m) => m.id === match[1])
if (msg) {
scrollToMessage(msg, behavior)
return
}
// If we have a message hash but the message isn't loaded/rendered yet,
// don't fall back to "bottom". We'll retry once messages arrive.
return
}
const target = document.getElementById(hash)
if (target) {
scrollToElement(target, behavior)
return
}
autoScroll.forceScrollToBottom()
}
const getActiveMessageId = (container: HTMLDivElement) => {
const cutoff = container.scrollTop + 100
const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]")
@@ -1022,31 +1088,47 @@ export default function Page() {
if (!sessionID || !ready) return
requestAnimationFrame(() => {
const hash = window.location.hash.slice(1)
if (!hash) {
autoScroll.forceScrollToBottom()
return
}
const hashTarget = document.getElementById(hash)
if (hashTarget) {
scrollToElement(hashTarget, "auto")
return
}
const match = hash.match(/^message-(.+)$/)
if (match) {
const msg = visibleUserMessages().find((m) => m.id === match[1])
if (msg) {
scrollToMessage(msg, "auto")
return
}
}
autoScroll.forceScrollToBottom()
applyHash("auto")
})
})
// Retry message navigation once the target message is actually loaded.
createEffect(() => {
const sessionID = params.id
const ready = messagesReady()
if (!sessionID || !ready) return
// dependencies
visibleUserMessages().length
store.turnStart
const targetId =
pendingMessage() ??
(() => {
const hash = window.location.hash.slice(1)
const match = hash.match(/^message-(.+)$/)
if (!match) return undefined
return match[1]
})()
if (!targetId) return
if (store.messageId === targetId) return
const msg = visibleUserMessages().find((m) => m.id === targetId)
if (!msg) return
if (pendingMessage() === targetId) setPendingMessage(undefined)
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
})
createEffect(() => {
const sessionID = params.id
const ready = messagesReady()
if (!sessionID || !ready) return
const handler = () => requestAnimationFrame(() => applyHash("auto"))
window.addEventListener("hashchange", handler)
onCleanup(() => window.removeEventListener("hashchange", handler))
})
createEffect(() => {
document.addEventListener("keydown", handleKeyDown)
})
@@ -1096,8 +1178,8 @@ export default function Page() {
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
<SessionHeader />
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
{/* Mobile tab bar - only shown on mobile when there are diffs */}
<Show when={!isDesktop() && hasReview()}>
{/* Mobile tab bar - only shown on mobile when user opened review */}
<Show when={!isDesktop() && view().reviewPanel.opened()}>
<Tabs class="h-auto">
<Tabs.List>
<Tabs.Trigger
@@ -1114,7 +1196,10 @@ export default function Page() {
classes={{ button: "w-full" }}
onClick={() => setStore("mobileTab", "review")}
>
{reviewCount()} Files Changed
<Switch>
<Match when={hasReview()}>{reviewCount()} Files Changed</Match>
<Match when={true}>Review</Match>
</Switch>
</Tabs.Trigger>
</Tabs.List>
</Tabs>
@@ -1139,41 +1224,40 @@ export default function Page() {
when={!mobileReview()}
fallback={
<div class="relative h-full overflow-hidden">
<Show
when={diffsReady()}
fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>}
>
<SessionReviewTab
diffs={diffs}
view={view}
diffStyle="unified"
onViewFile={(path) => {
const value = file.tab(path)
tabs().open(value)
file.load(path)
}}
classes={{
root: "pb-[calc(var(--prompt-height,8rem)+24px)]",
header: "px-4",
container: "px-4",
}}
/>
</Show>
<Switch>
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>}
>
<SessionReviewTab
diffs={diffs}
view={view}
diffStyle="unified"
onViewFile={(path) => {
const value = file.tab(path)
tabs().open(value)
file.load(path)
}}
classes={{
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
header: "px-4",
container: "px-4",
}}
/>
</Show>
</Match>
<Match when={true}>
<div class="px-4 pt-18 pb-6 flex flex-col items-center justify-center text-center gap-3">
<Mark class="w-6 opacity-40" />
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet.</div>
</div>
</Match>
</Switch>
</div>
}
>
<div class="relative w-full h-full min-w-0">
<Show when={isDesktop()}>
<div class="absolute inset-0 pointer-events-none z-10">
<SessionMessageRail
messages={visibleUserMessages()}
current={activeMessage()}
onMessageSelect={scrollToMessage}
wide={!showTabs()}
class="pointer-events-auto"
/>
</div>
</Show>
<div
ref={setScrollRef}
onScroll={(e) => {
@@ -1182,11 +1266,29 @@ export default function Page() {
}}
onClick={autoScroll.handleInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar"
style={{ "--session-title-height": info()?.title ? "40px" : "0px" }}
>
<Show when={info()?.title}>
<div
classList={{
"sticky top-0 z-30 bg-background-stronger": true,
"w-full": true,
"px-4 md:px-6": true,
"md:max-w-200 md:mx-auto": !showTabs(),
}}
>
<div class="h-10 flex items-center">
<h1 class="text-16-medium text-text-strong truncate">{info()?.title}</h1>
</div>
</div>
</Show>
<div
ref={autoScroll.contentRef}
class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
classList={{
"w-full": true,
"md:max-w-200 md:mx-auto": !showTabs(),
"mt-0.5": !showTabs(),
"mt-0": showTabs(),
}}
@@ -1237,6 +1339,7 @@ export default function Page() {
data-message-id={message.id}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200": !showTabs(),
"last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]":
platform.platform !== "desktop",
"last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]":
@@ -1253,15 +1356,8 @@ export default function Page() {
}
classes={{
root: "min-w-0 w-full relative",
content:
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
container:
"px-4 md:px-6 " +
(!showTabs()
? "md:max-w-200 md:mx-auto"
: visibleUserMessages().length > 1
? "md:pr-6 md:pl-18"
: ""),
content: "flex flex-col justify-between !overflow-visible",
container: "w-full px-4 md:px-6",
}}
/>
</div>
@@ -1351,7 +1447,7 @@ export default function Page() {
<Tabs value={activeTab()} onChange={openTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List>
<Show when={reviewTab()}>
<Show when={true}>
<Tabs.Trigger value="review">
<div class="flex items-center gap-3">
<Show when={diffs()}>
@@ -1404,26 +1500,36 @@ export default function Page() {
</div>
</Tabs.List>
</div>
<Show when={reviewTab()}>
<Show when={true}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<Show
when={diffsReady()}
fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>}
>
<SessionReviewTab
diffs={diffs}
view={view}
diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle}
onViewFile={(path) => {
const value = file.tab(path)
tabs().open(value)
file.load(path)
}}
/>
</Show>
<Switch>
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>}
>
<SessionReviewTab
diffs={diffs}
view={view}
diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle}
onViewFile={(path) => {
const value = file.tab(path)
tabs().open(value)
file.load(path)
}}
/>
</Show>
</Match>
<Match when={true}>
<div class="px-6 pt-18 pb-6 flex flex-col items-center justify-center text-center gap-3">
<Mark class="w-6 opacity-40" />
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet.</div>
</div>
</Match>
</Switch>
</div>
</Show>
</Tabs.Content>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.1.25",
"version": "1.1.26",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.1.25",
"version": "1.1.26",
"private": true,
"type": "module",
"license": "MIT",
+1
View File
@@ -218,6 +218,7 @@ export namespace Billing {
customer: customer.customerID,
customer_update: {
name: "auto",
address: "auto",
},
}
: {
@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test"
import { getWeekBounds } from "./date"
describe("util.date.getWeekBounds", () => {
test("returns a Monday-based week for Sunday dates", () => {
const date = new Date("2026-01-18T12:00:00Z")
const bounds = getWeekBounds(date)
expect(bounds.start.toISOString()).toBe("2026-01-12T00:00:00.000Z")
expect(bounds.end.toISOString()).toBe("2026-01-19T00:00:00.000Z")
})
test("returns a seven day window", () => {
const date = new Date("2026-01-14T12:00:00Z")
const bounds = getWeekBounds(date)
const span = bounds.end.getTime() - bounds.start.getTime()
expect(span).toBe(7 * 24 * 60 * 60 * 1000)
})
})
+2 -2
View File
@@ -1,7 +1,7 @@
export function getWeekBounds(date: Date) {
const dayOfWeek = date.getUTCDay()
const offset = (date.getUTCDay() + 6) % 7
const start = new Date(date)
start.setUTCDate(date.getUTCDate() - dayOfWeek + 1)
start.setUTCDate(date.getUTCDate() - offset)
start.setUTCHours(0, 0, 0, 0)
const end = new Date(start)
end.setUTCDate(start.getUTCDate() + 7)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.1.25",
"version": "1.1.26",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -35,7 +35,7 @@ export const subjects = createSubjects({
const MY_THEME: Theme = {
...THEME_OPENAUTH,
logo: "https://opencode.ai/favicon.svg",
logo: "https://opencode.ai/favicon-v2.svg",
}
export default {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.1.25",
"version": "1.1.26",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+4 -4
View File
@@ -4,10 +4,10 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenCode</title>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" href="/favicon-96x96-v2.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon-v2.svg" />
<link rel="shortcut icon" href="/favicon-v2.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v2.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#F8F7F7" />
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.1.25",
"version": "1.1.26",
"type": "module",
"license": "MIT",
"scripts": {
+11 -12
View File
@@ -26,17 +26,16 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
)
}
const isWindows = ostype() === "windows"
if (isWindows) {
const originalGetComputedStyle = window.getComputedStyle
window.getComputedStyle = ((elt: Element, pseudoElt?: string | null) => {
if (!(elt instanceof Element)) {
// WebView2 can call into Floating UI with non-elements; fall back to a safe element.
return originalGetComputedStyle(document.documentElement, pseudoElt ?? undefined)
}
return originalGetComputedStyle(elt, pseudoElt ?? undefined)
}) as typeof window.getComputedStyle
}
// Floating UI can call getComputedStyle with non-elements (e.g., null refs, virtual elements).
// This happens on all platforms (WebView2 on Windows, WKWebView on macOS), not just Windows.
const originalGetComputedStyle = window.getComputedStyle
window.getComputedStyle = ((elt: Element, pseudoElt?: string | null) => {
if (!(elt instanceof Element)) {
// Fall back to a safe element when a non-element is passed.
return originalGetComputedStyle(document.documentElement, pseudoElt ?? undefined)
}
return originalGetComputedStyle(elt, pseudoElt ?? undefined)
}) as typeof window.getComputedStyle
let update: Update | null = null
@@ -254,7 +253,7 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
.then(() => {
const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96.png",
icon: "https://opencode.ai/favicon-96x96-v2.png",
})
notification.onclick = () => {
const win = getCurrentWindow()
+1 -1
View File
@@ -7,7 +7,7 @@
"light": "#07C983",
"dark": "#15803D"
},
"favicon": "/favicon.svg",
"favicon": "/favicon-v2.svg",
"navigation": {
"tabs": [
{
+19
View File
@@ -0,0 +1,19 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.06145 23.1079C5.26816 22.3769 -3.39077 20.6274 1.4173 5.06384C9.6344 6.09939 16.9728 14.0644 9.06145 23.1079Z" fill="url(#paint0_linear_17557_2021)"/>
<path d="M8.91928 23.0939C5.27642 21.2223 0.78371 4.20891 17.0071 0C20.7569 7.19341 19.6212 16.5452 8.91928 23.0939Z" fill="url(#paint1_linear_17557_2021)"/>
<path d="M8.91388 23.0788C8.73534 19.8817 10.1585 9.08525 23.5699 13.1107C23.1812 20.1229 18.984 26.4182 8.91388 23.0788Z" fill="url(#paint2_linear_17557_2021)"/>
<defs>
<linearGradient id="paint0_linear_17557_2021" x1="3.77557" y1="5.91571" x2="5.23185" y2="21.5589" gradientUnits="userSpaceOnUse">
<stop stop-color="#18E299"/>
<stop offset="1" stop-color="#15803D"/>
</linearGradient>
<linearGradient id="paint1_linear_17557_2021" x1="12.1711" y1="-0.718425" x2="10.1897" y2="22.9832" gradientUnits="userSpaceOnUse">
<stop stop-color="#16A34A"/>
<stop offset="1" stop-color="#4ADE80"/>
</linearGradient>
<linearGradient id="paint2_linear_17557_2021" x1="23.1327" y1="15.353" x2="9.33841" y2="18.5196" gradientUnits="userSpaceOnUse">
<stop stop-color="#4ADE80"/>
<stop offset="1" stop-color="#0D9373"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.1.25",
"version": "1.1.26",
"private": true,
"type": "module",
"license": "MIT",
@@ -16,7 +16,6 @@ import { iife } from "@opencode-ai/util/iife"
import { Binary } from "@opencode-ai/util/binary"
import { NamedError } from "@opencode-ai/util/error"
import { DateTime } from "luxon"
import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
import { createStore } from "solid-js/store"
import z from "zod"
import NotFound from "../[...404]"
@@ -296,13 +295,13 @@ export default function () {
{(message) => (
<SessionTurn
sessionID={data().sessionID}
sessionTitle={info().title}
messageID={message.id}
stepsExpanded={store.expandedSteps[message.id] ?? false}
onStepsExpandedToggle={() => setStore("expandedSteps", message.id, (v) => !v)}
classes={{
root: "min-w-0 w-full relative",
content:
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
content: "flex flex-col justify-between !overflow-visible",
container: "px-4",
}}
/>
@@ -353,26 +352,16 @@ export default function () {
<div
classList={{
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full": true,
"mx-auto max-w-200": !wide(),
}}
>
<div
classList={{
"w-full flex justify-start items-start min-w-0": true,
"max-w-200 mx-auto px-6": wide(),
"pr-6 pl-18": !wide() && messages().length > 1,
"px-6": !wide() && messages().length === 1,
"w-full flex justify-start items-start min-w-0 px-6": true,
}}
>
{title()}
</div>
<div class="flex items-start justify-start h-full min-h-0">
<SessionMessageRail
messages={messages()}
current={activeMessage()}
onMessageSelect={setActiveMessage}
wide={wide()}
/>
<SessionTurn
sessionID={data().sessionID}
messageID={store.messageId ?? firstUserMessage()!.id!}
@@ -386,13 +375,7 @@ export default function () {
classes={{
root: "grow",
content: "flex flex-col justify-between",
container:
"w-full pb-20 " +
(wide()
? "max-w-200 mx-auto px-6"
: messages().length > 1
? "pr-6 pl-18"
: "px-6"),
container: "w-full pb-20 px-6",
}}
>
<div
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.1.25"
version = "1.1.26"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.1.25",
"version": "1.1.26",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.1.25",
"version": "1.1.26",
"name": "opencode",
"type": "module",
"license": "MIT",
+50
View File
@@ -0,0 +1,50 @@
const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd()
const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session"
const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e"
const model = process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano"
const parts = model.split("/")
const providerID = parts[0] ?? "opencode"
const modelID = parts[1] ?? "gpt-5-nano"
const now = Date.now()
const seed = async () => {
const { Instance } = await import("../src/project/instance")
const { InstanceBootstrap } = await import("../src/project/bootstrap")
const { Session } = await import("../src/session")
const { Identifier } = await import("../src/id/id")
const { Project } = await import("../src/project/project")
await Instance.provide({
directory: dir,
init: InstanceBootstrap,
fn: async () => {
const session = await Session.create({ title })
const messageID = Identifier.descending("message")
const partID = Identifier.descending("part")
const message = {
id: messageID,
sessionID: session.id,
role: "user" as const,
time: { created: now },
agent: "build",
model: {
providerID,
modelID,
},
}
const part = {
id: partID,
sessionID: session.id,
messageID,
type: "text" as const,
text,
time: { start: now },
}
await Session.updateMessage(message)
await Session.updatePart(part)
await Project.update({ projectID: Instance.project.id, name: "E2E Project" })
},
})
}
await seed()
+24 -3
View File
@@ -1,10 +1,12 @@
import { Config } from "../config/config"
import z from "zod"
import { Provider } from "../provider/provider"
import { generateObject, type ModelMessage } from "ai"
import { generateObject, streamObject, type ModelMessage } from "ai"
import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance"
import { Truncate } from "../tool/truncation"
import { Auth } from "../auth"
import { ProviderTransform } from "../provider/transform"
import PROMPT_GENERATE from "./generate.txt"
import PROMPT_COMPACTION from "./prompt/compaction.txt"
@@ -276,10 +278,12 @@ export namespace Agent {
const defaultModel = input.model ?? (await Provider.defaultModel())
const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID)
const language = await Provider.getLanguage(model)
const system = SystemPrompt.header(defaultModel.providerID)
system.push(PROMPT_GENERATE)
const existing = await list()
const result = await generateObject({
const params = {
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
metadata: {
@@ -305,7 +309,24 @@ export namespace Agent {
whenToUse: z.string(),
systemPrompt: z.string(),
}),
})
} satisfies Parameters<typeof generateObject>[0]
if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") {
const result = streamObject({
...params,
providerOptions: ProviderTransform.providerOptions(model, {
instructions: SystemPrompt.instructions(),
store: false,
}),
onError: () => {},
})
for await (const part of result.fullStream) {
if (part.type === "error") throw part.error
}
return result.object
}
const result = await generateObject(params)
return result.object
}
}
@@ -85,6 +85,7 @@ export function Autocomplete(props: {
index: 0,
selected: 0,
visible: false as AutocompleteRef["visible"],
input: "keyboard" as "keyboard" | "mouse",
})
const [positionTick, setPositionTick] = createSignal(0)
@@ -128,6 +129,14 @@ export function Autocomplete(props: {
return props.input().getTextRange(store.index + 1, props.input().cursorOffset)
})
// When the filter changes due to how TUI works, the mousemove might still be triggered
// via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard so
// that the mouseover event doesn't trigger when filtering.
createEffect(() => {
filter()
setStore("input", "keyboard")
})
function insertPart(text: string, part: PromptInfo["parts"][number]) {
const input = props.input()
const currentCursorOffset = input.cursorOffset
@@ -525,11 +534,13 @@ export function Autocomplete(props: {
const isNavDown = name === "down" || (ctrlOnly && name === "n")
if (isNavUp) {
setStore("input", "keyboard")
move(-1)
e.preventDefault()
return
}
if (isNavDown) {
setStore("input", "keyboard")
move(1)
e.preventDefault()
return
@@ -612,7 +623,17 @@ export function Autocomplete(props: {
paddingRight={1}
backgroundColor={index === store.selected ? theme.primary : undefined}
flexDirection="row"
onMouseOver={() => moveTo(index)}
onMouseMove={() => {
setStore("input", "mouse")
}}
onMouseOver={() => {
if (store.input !== "mouse") return
moveTo(index)
}}
onMouseDown={() => {
setStore("input", "mouse")
moveTo(index)
}}
onMouseUp={() => select()}
>
<text fg={index === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
@@ -106,7 +106,7 @@ const TIPS = [
"Use plugins to send OS notifications when sessions complete",
"Create a plugin to prevent OpenCode from reading sensitive files",
"Use {highlight}opencode run{/highlight} for non-interactive scripting",
"Use {highlight}opencode run --continue{/highlight} to resume the last session",
"Use {highlight}opencode --continue{/highlight} to resume the last session",
"Use {highlight}opencode run -f file.ts{/highlight} to attach files via CLI",
"Use {highlight}--format json{/highlight} for machine-readable output in scripts",
"Run {highlight}opencode serve{/highlight} for headless API access to OpenCode",
@@ -280,6 +280,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
reply: "reject",
requestID: props.request.id,
})
return
}
sdk.client.permission.reply({
reply: "once",
@@ -456,6 +457,11 @@ function Prompt<const T extends Record<string, string>>(props: {
paddingLeft={1}
paddingRight={1}
backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
onMouseOver={() => setStore("selected", option)}
onMouseUp={() => {
setStore("selected", option)
props.onSelect(option)
}}
>
<text fg={option === store.selected ? selectedForeground(theme, theme.warning) : theme.textMuted}>
{props.options[option]}
@@ -52,6 +52,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const [store, setStore] = createStore({
selected: 0,
filter: "",
input: "keyboard" as "keyboard" | "mouse",
})
createEffect(
@@ -83,6 +84,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
return result
})
// When the filter changes due to how TUI works, the mousemove might still be triggered
// via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard
// that the mouseover event doesn't trigger when filtering.
createEffect(() => {
filtered()
setStore("input", "keyboard")
})
const grouped = createMemo(() => {
const result = pipe(
filtered(),
@@ -157,12 +166,15 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const keybind = useKeybind()
useKeyboard((evt) => {
setStore("input", "keyboard")
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1)
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
if (evt.name === "pageup") move(-10)
if (evt.name === "pagedown") move(10)
if (evt.name === "home") moveTo(0)
if (evt.name === "end") moveTo(flat().length - 1)
if (evt.name === "return") {
const option = selected()
if (option) {
@@ -259,11 +271,20 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<box
id={JSON.stringify(option.value)}
flexDirection="row"
onMouseMove={() => {
setStore("input", "mouse")
}}
onMouseUp={() => {
option.onSelect?.(dialog)
props.onSelect?.(option)
}}
onMouseOver={() => {
if (store.input !== "mouse") return
const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
if (index === -1) return
moveTo(index)
}}
onMouseDown={() => {
const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
if (index === -1) return
moveTo(index)
@@ -337,6 +358,7 @@ function Option(props: {
fg={props.active ? fg : props.current ? theme.primary : theme.text}
attributes={props.active ? TextAttributes.BOLD : undefined}
overflow="hidden"
wrapMode="none"
paddingLeft={3}
>
{Locale.truncate(props.title, 61)}
@@ -125,9 +125,25 @@ export namespace Clipboard {
if (os === "win32") {
console.log("clipboard: using powershell")
return async (text: string) => {
// need to escape backticks because powershell uses them as escape code
const escaped = text.replace(/"/g, '""').replace(/`/g, "``")
await $`powershell -NonInteractive -NoProfile -Command "Set-Clipboard -Value \"${escaped}\""`.nothrow().quiet()
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
const proc = Bun.spawn(
[
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
],
{
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
},
)
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
+24 -26
View File
@@ -162,34 +162,32 @@ export namespace Ripgrep {
})
}
if (config.extension === "zip") {
if (config.extension === "zip") {
const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()])))
const entries = await zipFileReader.getEntries()
let rgEntry: any
for (const entry of entries) {
if (entry.filename.endsWith("rg.exe")) {
rgEntry = entry
break
}
const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()])))
const entries = await zipFileReader.getEntries()
let rgEntry: any
for (const entry of entries) {
if (entry.filename.endsWith("rg.exe")) {
rgEntry = entry
break
}
if (!rgEntry) {
throw new ExtractionFailedError({
filepath: archivePath,
stderr: "rg.exe not found in zip archive",
})
}
const rgBlob = await rgEntry.getData(new BlobWriter())
if (!rgBlob) {
throw new ExtractionFailedError({
filepath: archivePath,
stderr: "Failed to extract rg.exe from zip archive",
})
}
await Bun.write(filepath, await rgBlob.arrayBuffer())
await zipFileReader.close()
}
if (!rgEntry) {
throw new ExtractionFailedError({
filepath: archivePath,
stderr: "rg.exe not found in zip archive",
})
}
const rgBlob = await rgEntry.getData(new BlobWriter())
if (!rgBlob) {
throw new ExtractionFailedError({
filepath: archivePath,
stderr: "Failed to extract rg.exe from zip archive",
})
}
await Bun.write(filepath, await rgBlob.arrayBuffer())
await zipFileReader.close()
}
await fs.unlink(archivePath)
if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755)
+2 -19
View File
@@ -226,7 +226,7 @@ export const rlang: Info = {
}
export const uvformat: Info = {
name: "uv format",
name: "uv",
command: ["uv", "format", "--", "$FILE"],
extensions: [".py", ".pyi"],
async enabled() {
@@ -337,23 +337,6 @@ export const rustfmt: Info = {
command: ["rustfmt", "$FILE"],
extensions: [".rs"],
async enabled() {
if (!Bun.which("rustfmt")) return false
const configs = ["rustfmt.toml", ".rustfmt.toml"]
for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
if (found.length > 0) return true
}
return false
},
}
export const cargofmt: Info = {
name: "cargofmt",
command: ["cargo", "fmt", "--", "$FILE"],
extensions: [".rs"],
async enabled() {
if (!Bun.which("cargo")) return false
const found = await Filesystem.findUp("Cargo.toml", Instance.directory, Instance.worktree)
return found.length > 0
return Bun.which("rustfmt") !== null
},
}
@@ -11,6 +11,8 @@ import { Instance } from "./instance"
import { Vcs } from "./vcs"
import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next"
import { Snapshot } from "../snapshot"
import { Truncate } from "../tool/truncation"
export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory })
@@ -22,6 +24,8 @@ export async function InstanceBootstrap() {
FileWatcher.init()
File.init()
Vcs.init()
Snapshot.init()
Truncate.init()
Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {
+19
View File
@@ -25,6 +25,7 @@ export namespace Project {
icon: z
.object({
url: z.string().optional(),
override: z.string().optional(),
color: z.string().optional(),
})
.optional(),
@@ -190,6 +191,7 @@ export namespace Project {
if (!existing.sandboxes) existing.sandboxes = []
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing)
const result: Info = {
...existing,
worktree,
@@ -213,6 +215,7 @@ export namespace Project {
export async function discover(input: Info) {
if (input.vcs !== "git") return
if (input.icon?.override) return
if (input.icon?.url) return
const glob = new Bun.Glob("**/{favicon}.{ico,png,svg,jpg,jpeg,webp}")
const matches = await Array.fromAsync(
@@ -293,6 +296,7 @@ export namespace Project {
...draft.icon,
}
if (input.icon.url !== undefined) draft.icon.url = input.icon.url
if (input.icon.override !== undefined) draft.icon.override = input.icon.override || undefined
if (input.icon.color !== undefined) draft.icon.color = input.icon.color
}
draft.time.updated = Date.now()
@@ -317,4 +321,19 @@ export namespace Project {
}
return valid
}
export async function removeSandbox(projectID: string, directory: string) {
const result = await Storage.update<Info>(["project", projectID], (draft) => {
const sandboxes = draft.sandboxes ?? []
draft.sandboxes = sandboxes.filter((sandbox) => sandbox !== directory)
draft.time.updated = Date.now()
})
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
properties: result,
},
})
return result
}
}
+18 -19
View File
@@ -41,6 +41,18 @@ import { ProviderTransform } from "./transform"
export namespace Provider {
const log = Log.create({ service: "provider" })
function isGpt5OrLater(modelID: string): boolean {
const match = /^gpt-(\d+)/.exec(modelID)
if (!match) {
return false
}
return Number(match[1]) >= 5
}
function shouldUseCopilotResponsesApi(modelID: string): boolean {
return isGpt5OrLater(modelID) && !modelID.startsWith("gpt-5-mini")
}
const BUNDLED_PROVIDERS: Record<string, (options: any) => SDK> = {
"@ai-sdk/amazon-bedrock": createAmazonBedrock,
"@ai-sdk/anthropic": createAnthropic,
@@ -120,10 +132,7 @@ export namespace Provider {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
if (modelID.includes("codex")) {
return sdk.responses(modelID)
}
return sdk.chat(modelID)
return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
},
options: {},
}
@@ -132,10 +141,7 @@ export namespace Provider {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
if (modelID.includes("codex")) {
return sdk.responses(modelID)
}
return sdk.chat(modelID)
return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
},
options: {},
}
@@ -595,7 +601,10 @@ export namespace Provider {
api: {
id: model.id,
url: provider.api!,
npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
npm: iife(() => {
if (provider.id.startsWith("github-copilot")) return "@ai-sdk/github-copilot"
return model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible"
}),
},
status: model.status ?? "active",
headers: model.headers ?? {},
@@ -902,16 +911,6 @@ export namespace Provider {
continue
}
if (providerID === "github-copilot" || providerID === "github-copilot-enterprise") {
provider.models = mapValues(provider.models, (model) => ({
...model,
api: {
...model.api,
npm: "@ai-sdk/github-copilot",
},
}))
}
const configProvider = config.provider?.[providerID]
for (const [modelID, model] of Object.entries(provider.models)) {
@@ -815,14 +815,20 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
// flag that checks if there have been client-side tool calls (not executed by openai)
let hasFunctionCall = false
// Track reasoning by output_index instead of item_id
// GitHub Copilot rotates encrypted item IDs on every event
const activeReasoning: Record<
string,
number,
{
canonicalId: string // the item.id from output_item.added
encryptedContent?: string | null
summaryParts: number[]
}
> = {}
// Track current active reasoning output_index for correlating summary events
let currentReasoningOutputIndex: number | null = null
// Track a stable text part id for the current assistant message.
// Copilot may change item_id across text deltas; normalize to one id.
let currentTextId: string | null = null
@@ -933,10 +939,12 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
},
})
} else if (isResponseOutputItemAddedReasoningChunk(value)) {
activeReasoning[value.item.id] = {
activeReasoning[value.output_index] = {
canonicalId: value.item.id,
encryptedContent: value.item.encrypted_content,
summaryParts: [0],
}
currentReasoningOutputIndex = value.output_index
controller.enqueue({
type: "reasoning-start",
@@ -1091,22 +1099,25 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
currentTextId = null
}
} else if (isResponseOutputItemDoneReasoningChunk(value)) {
const activeReasoningPart = activeReasoning[value.item.id]
const activeReasoningPart = activeReasoning[value.output_index]
if (activeReasoningPart) {
for (const summaryIndex of activeReasoningPart.summaryParts) {
controller.enqueue({
type: "reasoning-end",
id: `${value.item.id}:${summaryIndex}`,
id: `${activeReasoningPart.canonicalId}:${summaryIndex}`,
providerMetadata: {
openai: {
itemId: value.item.id,
itemId: activeReasoningPart.canonicalId,
reasoningEncryptedContent: value.item.encrypted_content ?? null,
},
},
})
}
delete activeReasoning[value.output_index]
if (currentReasoningOutputIndex === value.output_index) {
currentReasoningOutputIndex = null
}
}
delete activeReasoning[value.item.id]
}
} else if (isResponseFunctionCallArgumentsDeltaChunk(value)) {
const toolCall = ongoingToolCalls[value.output_index]
@@ -1198,32 +1209,40 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
logprobs.push(value.logprobs)
}
} else if (isResponseReasoningSummaryPartAddedChunk(value)) {
const activeItem =
currentReasoningOutputIndex !== null ? activeReasoning[currentReasoningOutputIndex] : null
// the first reasoning start is pushed in isResponseOutputItemAddedReasoningChunk.
if (value.summary_index > 0) {
activeReasoning[value.item_id]?.summaryParts.push(value.summary_index)
if (activeItem && value.summary_index > 0) {
activeItem.summaryParts.push(value.summary_index)
controller.enqueue({
type: "reasoning-start",
id: `${value.item_id}:${value.summary_index}`,
id: `${activeItem.canonicalId}:${value.summary_index}`,
providerMetadata: {
openai: {
itemId: value.item_id,
reasoningEncryptedContent: activeReasoning[value.item_id]?.encryptedContent ?? null,
itemId: activeItem.canonicalId,
reasoningEncryptedContent: activeItem.encryptedContent ?? null,
},
},
})
}
} else if (isResponseReasoningSummaryTextDeltaChunk(value)) {
controller.enqueue({
type: "reasoning-delta",
id: `${value.item_id}:${value.summary_index}`,
delta: value.delta,
providerMetadata: {
openai: {
itemId: value.item_id,
const activeItem =
currentReasoningOutputIndex !== null ? activeReasoning[currentReasoningOutputIndex] : null
if (activeItem) {
controller.enqueue({
type: "reasoning-delta",
id: `${activeItem.canonicalId}:${value.summary_index}`,
delta: value.delta,
providerMetadata: {
openai: {
itemId: activeItem.canonicalId,
},
},
},
})
})
}
} else if (isResponseFinishedChunk(value)) {
finishReason = mapOpenAIResponseFinishReason({
finishReason: value.response.incomplete_details?.reason,
+21 -8
View File
@@ -123,11 +123,8 @@ export namespace ProviderTransform {
return result
}
if (
model.capabilities.interleaved &&
typeof model.capabilities.interleaved === "object" &&
model.capabilities.interleaved.field === "reasoning_content"
) {
if (typeof model.capabilities.interleaved === "object" && model.capabilities.interleaved.field) {
const field = model.capabilities.interleaved.field
return msgs.map((msg) => {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning")
@@ -136,7 +133,7 @@ export namespace ProviderTransform {
// Filter out reasoning parts from content
const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning")
// Include reasoning_content directly on the message for all assistant messages
// Include reasoning_content | reasoning_details directly on the message for all assistant messages
if (reasoningText) {
return {
...msg,
@@ -145,7 +142,7 @@ export namespace ProviderTransform {
...msg.providerOptions,
openaiCompatible: {
...(msg.providerOptions as any)?.openaiCompatible,
reasoning_content: reasoningText,
[field]: reasoningText,
},
},
}
@@ -349,6 +346,18 @@ export namespace ProviderTransform {
case "@ai-sdk/gateway":
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
case "@ai-sdk/github-copilot":
return Object.fromEntries(
WIDELY_SUPPORTED_EFFORTS.map((effort) => [
effort,
{
reasoningEffort: effort,
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
]),
)
case "@ai-sdk/cerebras":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/cerebras
case "@ai-sdk/togetherai":
@@ -524,7 +533,11 @@ export namespace ProviderTransform {
const result: Record<string, any> = {}
// openai and providers using openai package should set store to false by default.
if (input.model.providerID === "openai" || input.model.api.npm === "@ai-sdk/openai") {
if (
input.model.providerID === "openai" ||
input.model.api.npm === "@ai-sdk/openai" ||
input.model.api.npm === "@ai-sdk/github-copilot"
) {
result["store"] = false
}
+61
View File
@@ -0,0 +1,61 @@
import { Instance } from "../project/instance"
import { Log } from "../util/log"
export namespace Scheduler {
const log = Log.create({ service: "scheduler" })
export type Task = {
id: string
interval: number
run: () => Promise<void>
scope?: "instance" | "global"
}
type Timer = ReturnType<typeof setInterval>
type Entry = {
tasks: Map<string, Task>
timers: Map<string, Timer>
}
const create = (): Entry => {
const tasks = new Map<string, Task>()
const timers = new Map<string, Timer>()
return { tasks, timers }
}
const shared = create()
const state = Instance.state(
() => create(),
async (entry) => {
for (const timer of entry.timers.values()) {
clearInterval(timer)
}
entry.tasks.clear()
entry.timers.clear()
},
)
export function register(task: Task) {
const scope = task.scope ?? "instance"
const entry = scope === "global" ? shared : state()
const current = entry.timers.get(task.id)
if (current && scope === "global") return
if (current) clearInterval(current)
entry.tasks.set(task.id, task)
void run(task)
const timer = setInterval(() => {
void run(task)
}, task.interval)
timer.unref()
entry.timers.set(task.id, timer)
}
async function run(task: Task) {
log.info("run", { id: task.id })
await task.run().catch((error) => {
log.error("run failed", { id: task.id, error })
})
}
}
@@ -133,6 +133,57 @@ export const ExperimentalRoutes = lazy(() =>
return c.json(sandboxes)
},
)
.delete(
"/worktree",
describeRoute({
summary: "Remove worktree",
description: "Remove a git worktree and delete its branch.",
operationId: "worktree.remove",
responses: {
200: {
description: "Worktree removed",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator("json", Worktree.remove.schema),
async (c) => {
const body = c.req.valid("json")
await Worktree.remove(body)
await Project.removeSandbox(Instance.project.id, body.directory)
return c.json(true)
},
)
.post(
"/worktree/reset",
describeRoute({
summary: "Reset worktree",
description: "Reset a worktree branch to the primary default branch.",
operationId: "worktree.reset",
responses: {
200: {
description: "Worktree reset",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator("json", Worktree.reset.schema),
async (c) => {
const body = c.req.valid("json")
await Worktree.reset(body)
return c.json(true)
},
)
.get(
"/resource",
describeRoute({
@@ -15,7 +15,10 @@ export namespace ShareNext {
return Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
}
const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1"
export async function init() {
if (disabled) return
Bus.subscribe(Session.Event.Updated, async (evt) => {
await sync(evt.properties.info.id, [
{
@@ -63,6 +66,7 @@ export namespace ShareNext {
}
export async function create(sessionID: string) {
if (disabled) return { id: "", url: "", secret: "" }
log.info("creating share", { sessionID })
const result = await fetch(`${await url()}/api/share`, {
method: "POST",
@@ -110,6 +114,7 @@ export namespace ShareNext {
const queue = new Map<string, { timeout: NodeJS.Timeout; data: Map<string, Data> }>()
async function sync(sessionID: string, data: Data[]) {
if (disabled) return
const existing = queue.get(sessionID)
if (existing) {
for (const item of data) {
@@ -145,6 +150,7 @@ export namespace ShareNext {
}
export async function remove(sessionID: string) {
if (disabled) return
log.info("removing share", { sessionID })
const share = await get(sessionID)
if (!share) return
+5
View File
@@ -11,6 +11,7 @@ export namespace Share {
const pending = new Map<string, any>()
export async function sync(key: string, content: any) {
if (disabled) return
const [root, ...splits] = key.split("/")
if (root !== "session") return
const [sub, sessionID] = splits
@@ -69,7 +70,10 @@ export namespace Share {
process.env["OPENCODE_API"] ??
(Installation.isPreview() || Installation.isLocal() ? "https://api.dev.opencode.ai" : "https://api.opencode.ai")
const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1"
export async function create(sessionID: string) {
if (disabled) return { url: "", secret: "" }
return fetch(`${URL}/share_create`, {
method: "POST",
body: JSON.stringify({ sessionID: sessionID }),
@@ -79,6 +83,7 @@ export namespace Share {
}
export async function remove(sessionID: string, secret: string) {
if (disabled) return {}
return fetch(`${URL}/share_delete`, {
method: "POST",
body: JSON.stringify({ sessionID, secret }),
+37
View File
@@ -6,9 +6,46 @@ import { Global } from "../global"
import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Scheduler } from "../scheduler"
export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
const hour = 60 * 60 * 1000
const prune = "7.days"
export function init() {
Scheduler.register({
id: "snapshot.cleanup",
interval: hour,
run: cleanup,
scope: "instance",
})
}
export async function cleanup() {
if (Instance.project.vcs !== "git") return
const cfg = await Config.get()
if (cfg.snapshot === false) return
const git = gitdir()
const exists = await fs
.stat(git)
.then(() => true)
.catch(() => false)
if (!exists) return
const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}`
.quiet()
.cwd(Instance.directory)
.nothrow()
if (result.exitCode !== 0) {
log.warn("cleanup failed", {
exitCode: result.exitCode,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
return
}
log.info("cleanup", { prune })
}
export async function track() {
if (Instance.project.vcs !== "git") return
+2 -1
View File
@@ -12,13 +12,14 @@ import { assertExternalDirectory } from "./external-directory"
import { trimDiff } from "./edit"
import { LSP } from "../lsp"
import { Filesystem } from "../util/filesystem"
import DESCRIPTION from "./apply_patch.txt"
const PatchParams = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"),
})
export const ApplyPatchTool = Tool.define("apply_patch", {
description: "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.",
description: DESCRIPTION,
parameters: PatchParams,
async execute(params, ctx) {
if (!params.patchText) {
+33 -1
View File
@@ -1 +1,33 @@
Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.
Use the `apply_patch` tool to edit files. Your patch language is a strippeddown, fileoriented diff format designed to be easy to parse and safe to apply. You can think of it as a highlevel envelope:
*** Begin Patch
[ one or more file sections ]
*** End Patch
Within that envelope, you get a sequence of file operations.
You MUST include a header to specify the action you are taking.
Each operation starts with one of three headers:
*** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
*** Delete File: <path> - remove an existing file. Nothing follows.
*** Update File: <path> - patch an existing file in place (optionally with a rename).
Example patch:
```
*** Begin Patch
*** Add File: hello.txt
+Hello world
*** Update File: src/app.py
*** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
*** Delete File: obsolete.txt
*** End Patch
```
It is important to remember:
- You must include a header with your intended action (Add/Delete/Update)
- You must prefix new lines with `+` even when creating a new file
+4 -4
View File
@@ -33,8 +33,8 @@ export const BatchTool = Tool.define("batch", async () => {
const { Session } = await import("../session")
const { Identifier } = await import("../id/id")
const toolCalls = params.tool_calls.slice(0, 10)
const discardedCalls = params.tool_calls.slice(10)
const toolCalls = params.tool_calls.slice(0, 25)
const discardedCalls = params.tool_calls.slice(25)
const { ToolRegistry } = await import("./registry")
const availableTools = await ToolRegistry.tools({ modelID: "", providerID: "" })
@@ -139,14 +139,14 @@ export const BatchTool = Tool.define("batch", async () => {
state: {
status: "error",
input: call.parameters,
error: "Maximum of 10 tools allowed in batch",
error: "Maximum of 25 tools allowed in batch",
time: { start: now, end: now },
},
})
results.push({
success: false as const,
tool: call.tool,
error: new Error("Maximum of 10 tools allowed in batch"),
error: new Error("Maximum of 25 tools allowed in batch"),
})
}
+1 -1
View File
@@ -6,7 +6,7 @@ Payload Format (JSON array):
[{"tool": "read", "parameters": {"filePath": "src/index.ts", "limit": 350}},{"tool": "grep", "parameters": {"pattern": "Session\\.updatePart", "include": "src/**/*.ts"}},{"tool": "bash", "parameters": {"command": "git status", "description": "Shows working tree status"}}]
Notes:
- 110 tool calls per batch
- 120 tool calls per batch
- All calls start in parallel; ordering NOT guaranteed
- Partial failures do not stop other tool calls
- Do NOT use the batch tool within another batch tool.
+5
View File
@@ -143,6 +143,11 @@ export namespace ToolRegistry {
if (t.id === "apply_patch") return usePatch
if (t.id === "edit" || t.id === "write") return !usePatch
// omit todo tools for openai models
if (t.id === "todoread" || t.id === "todowrite") {
if (model.modelID.includes("gpt-")) return false
}
return true
})
.map(async (t) => {
+11 -4
View File
@@ -2,9 +2,9 @@ import fs from "fs/promises"
import path from "path"
import { Global } from "../global"
import { Identifier } from "../id/id"
import { lazy } from "../util/lazy"
import { PermissionNext } from "../permission/next"
import type { Agent } from "../agent/agent"
import { Scheduler } from "../scheduler"
export namespace Truncate {
export const MAX_LINES = 2000
@@ -12,6 +12,7 @@ export namespace Truncate {
export const DIR = path.join(Global.Path.data, "tool-output")
export const GLOB = path.join(DIR, "*")
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
const HOUR_MS = 60 * 60 * 1000
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
@@ -21,6 +22,15 @@ export namespace Truncate {
direction?: "head" | "tail"
}
export function init() {
Scheduler.register({
id: "tool.truncation.cleanup",
interval: HOUR_MS,
run: cleanup,
scope: "global",
})
}
export async function cleanup() {
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - RETENTION_MS))
const glob = new Bun.Glob("tool_*")
@@ -31,8 +41,6 @@ export namespace Truncate {
}
}
const init = lazy(cleanup)
function hasTaskTool(agent?: Agent.Info): boolean {
if (!agent?.permission) return false
const rule = PermissionNext.evaluate("task", "*", agent.permission)
@@ -81,7 +89,6 @@ export namespace Truncate {
const unit = hitBytes ? "bytes" : "lines"
const preview = out.join("\n")
await init()
const id = Identifier.ascending("tool")
const filepath = path.join(DIR, id)
await Bun.write(Bun.file(filepath), text)
+207
View File
@@ -33,6 +33,26 @@ export namespace Worktree {
export type CreateInput = z.infer<typeof CreateInput>
export const RemoveInput = z
.object({
directory: z.string(),
})
.meta({
ref: "WorktreeRemoveInput",
})
export type RemoveInput = z.infer<typeof RemoveInput>
export const ResetInput = z
.object({
directory: z.string(),
})
.meta({
ref: "WorktreeResetInput",
})
export type ResetInput = z.infer<typeof ResetInput>
export const NotGitError = NamedError.create(
"WorktreeNotGitError",
z.object({
@@ -61,6 +81,20 @@ export namespace Worktree {
}),
)
export const RemoveFailedError = NamedError.create(
"WorktreeRemoveFailedError",
z.object({
message: z.string(),
}),
)
export const ResetFailedError = NamedError.create(
"WorktreeResetFailedError",
z.object({
message: z.string(),
}),
)
const ADJECTIVES = [
"brave",
"calm",
@@ -214,4 +248,177 @@ export namespace Worktree {
return info
})
export const remove = fn(RemoveInput, async (input) => {
if (Instance.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const directory = path.resolve(input.directory)
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
if (list.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
}
const lines = outputText(list.stdout)
.split("\n")
.map((line) => line.trim())
const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
if (!line) return acc
if (line.startsWith("worktree ")) {
acc.push({ path: line.slice("worktree ".length).trim() })
return acc
}
const current = acc[acc.length - 1]
if (!current) return acc
if (line.startsWith("branch ")) {
current.branch = line.slice("branch ".length).trim()
}
return acc
}, [])
const entry = entries.find((item) => item.path && path.resolve(item.path) === directory)
if (!entry?.path) {
throw new RemoveFailedError({ message: "Worktree not found" })
}
const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
if (removed.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
}
const branch = entry.branch?.replace(/^refs\/heads\//, "")
if (branch) {
const deleted = await $`git branch -D ${branch}`.quiet().nothrow().cwd(Instance.worktree)
if (deleted.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" })
}
}
return true
})
export const reset = fn(ResetInput, async (input) => {
if (Instance.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const directory = path.resolve(input.directory)
if (directory === path.resolve(Instance.worktree)) {
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
}
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
if (list.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" })
}
const lines = outputText(list.stdout)
.split("\n")
.map((line) => line.trim())
const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
if (!line) return acc
if (line.startsWith("worktree ")) {
acc.push({ path: line.slice("worktree ".length).trim() })
return acc
}
const current = acc[acc.length - 1]
if (!current) return acc
if (line.startsWith("branch ")) {
current.branch = line.slice("branch ".length).trim()
}
return acc
}, [])
const entry = entries.find((item) => item.path && path.resolve(item.path) === directory)
if (!entry?.path) {
throw new ResetFailedError({ message: "Worktree not found" })
}
const remoteList = await $`git remote`.quiet().nothrow().cwd(Instance.worktree)
if (remoteList.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
}
const remotes = outputText(remoteList.stdout)
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
const remote = remotes.includes("origin")
? "origin"
: remotes.length === 1
? remotes[0]
: remotes.includes("upstream")
? "upstream"
: ""
const remoteHead = remote
? await $`git symbolic-ref refs/remotes/${remote}/HEAD`.quiet().nothrow().cwd(Instance.worktree)
: { exitCode: 1, stdout: undefined, stderr: undefined }
const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : ""
const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
const mainCheck = await $`git show-ref --verify --quiet refs/heads/main`.quiet().nothrow().cwd(Instance.worktree)
const masterCheck = await $`git show-ref --verify --quiet refs/heads/master`
.quiet()
.nothrow()
.cwd(Instance.worktree)
const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : ""
const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
if (!target) {
throw new ResetFailedError({ message: "Default branch not found" })
}
if (remoteBranch) {
const fetch = await $`git fetch ${remote} ${remoteBranch}`.quiet().nothrow().cwd(Instance.worktree)
if (fetch.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` })
}
}
if (!entry.path) {
throw new ResetFailedError({ message: "Worktree path not found" })
}
const worktreePath = entry.path
const resetToTarget = await $`git reset --hard ${target}`.quiet().nothrow().cwd(worktreePath)
if (resetToTarget.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" })
}
const clean = await $`git clean -fdx`.quiet().nothrow().cwd(worktreePath)
if (clean.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" })
}
const update = await $`git submodule update --init --recursive --force`.quiet().nothrow().cwd(worktreePath)
if (update.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" })
}
const subReset = await $`git submodule foreach --recursive git reset --hard`.quiet().nothrow().cwd(worktreePath)
if (subReset.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" })
}
const subClean = await $`git submodule foreach --recursive git clean -fdx`.quiet().nothrow().cwd(worktreePath)
if (subClean.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" })
}
const status = await $`git status --porcelain=v1`.quiet().nothrow().cwd(worktreePath)
if (status.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" })
}
const dirty = outputText(status.stdout)
if (!dirty) return true
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${dirty}` })
})
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test"
import { Scheduler } from "../src/scheduler"
import { Instance } from "../src/project/instance"
import { tmpdir } from "./fixture/fixture"
describe("Scheduler.register", () => {
const hour = 60 * 60 * 1000
test("defaults to instance scope per directory", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
const runs = { count: 0 }
const id = "scheduler.instance." + Math.random().toString(36).slice(2)
const task = {
id,
interval: hour,
run: async () => {
runs.count += 1
},
}
await Instance.provide({
directory: one.path,
fn: async () => {
Scheduler.register(task)
await Instance.dispose()
},
})
expect(runs.count).toBe(1)
await Instance.provide({
directory: two.path,
fn: async () => {
Scheduler.register(task)
await Instance.dispose()
},
})
expect(runs.count).toBe(2)
})
test("global scope runs once across instances", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
const runs = { count: 0 }
const id = "scheduler.global." + Math.random().toString(36).slice(2)
const task = {
id,
interval: hour,
run: async () => {
runs.count += 1
},
scope: "global" as const,
}
await Instance.provide({
directory: one.path,
fn: async () => {
Scheduler.register(task)
await Instance.dispose()
},
})
expect(runs.count).toBe(1)
await Instance.provide({
directory: two.path,
fn: async () => {
Scheduler.register(task)
await Instance.dispose()
},
})
expect(runs.count).toBe(1)
})
})
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.1.25",
"version": "1.1.26",
"type": "module",
"license": "MIT",
"scripts": {
@@ -25,4 +25,4 @@
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.1.25",
"version": "1.1.26",
"type": "module",
"license": "MIT",
"scripts": {
@@ -30,4 +30,4 @@
"publishConfig": {
"directory": "dist"
}
}
}
+77
View File
@@ -162,6 +162,12 @@ import type {
WorktreeCreateInput,
WorktreeCreateResponses,
WorktreeListResponses,
WorktreeRemoveErrors,
WorktreeRemoveInput,
WorktreeRemoveResponses,
WorktreeResetErrors,
WorktreeResetInput,
WorktreeResetResponses,
} from "./types.gen.js"
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<
@@ -296,6 +302,7 @@ export class Project extends HeyApiClient {
name?: string
icon?: {
url?: string
override?: string
color?: string
}
},
@@ -654,6 +661,41 @@ export class Tool extends HeyApiClient {
}
export class Worktree extends HeyApiClient {
/**
* Remove worktree
*
* Remove a git worktree and delete its branch.
*/
public remove<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
worktreeRemoveInput?: WorktreeRemoveInput
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ key: "worktreeRemoveInput", map: "body" },
],
},
],
)
return (options?.client ?? this.client).delete<WorktreeRemoveResponses, WorktreeRemoveErrors, ThrowOnError>({
url: "/experimental/worktree",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* List worktrees
*
@@ -707,6 +749,41 @@ export class Worktree extends HeyApiClient {
},
})
}
/**
* Reset worktree
*
* Reset a worktree branch to the primary default branch.
*/
public reset<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
worktreeResetInput?: WorktreeResetInput
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ key: "worktreeResetInput", map: "body" },
],
},
],
)
return (options?.client ?? this.client).post<WorktreeResetResponses, WorktreeResetErrors, ThrowOnError>({
url: "/experimental/worktree/reset",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
}
export class Resource extends HeyApiClient {
+64
View File
@@ -25,6 +25,7 @@ export type Project = {
name?: string
icon?: {
url?: string
override?: string
color?: string
}
time: {
@@ -1908,6 +1909,14 @@ export type WorktreeCreateInput = {
startCommand?: string
}
export type WorktreeRemoveInput = {
directory: string
}
export type WorktreeResetInput = {
directory: string
}
export type McpResource = {
name: string
uri: string
@@ -2221,6 +2230,7 @@ export type ProjectUpdateData = {
name?: string
icon?: {
url?: string
override?: string
color?: string
}
}
@@ -2554,6 +2564,33 @@ export type ToolListResponses = {
export type ToolListResponse = ToolListResponses[keyof ToolListResponses]
export type WorktreeRemoveData = {
body?: WorktreeRemoveInput
path?: never
query?: {
directory?: string
}
url: "/experimental/worktree"
}
export type WorktreeRemoveErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type WorktreeRemoveError = WorktreeRemoveErrors[keyof WorktreeRemoveErrors]
export type WorktreeRemoveResponses = {
/**
* Worktree removed
*/
200: boolean
}
export type WorktreeRemoveResponse = WorktreeRemoveResponses[keyof WorktreeRemoveResponses]
export type WorktreeListData = {
body?: never
path?: never
@@ -2599,6 +2636,33 @@ export type WorktreeCreateResponses = {
export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses]
export type WorktreeResetData = {
body?: WorktreeResetInput
path?: never
query?: {
directory?: string
}
url: "/experimental/worktree/reset"
}
export type WorktreeResetErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type WorktreeResetError = WorktreeResetErrors[keyof WorktreeResetErrors]
export type WorktreeResetResponses = {
/**
* Worktree reset
*/
200: boolean
}
export type WorktreeResetResponse = WorktreeResetResponses[keyof WorktreeResetResponses]
export type ExperimentalResourceListData = {
body?: never
path?: never
+128
View File
@@ -231,6 +231,9 @@
"url": {
"type": "string"
},
"override": {
"type": "string"
},
"color": {
"type": "string"
}
@@ -920,6 +923,110 @@
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.list({\n ...\n})"
}
]
},
"delete": {
"operationId": "worktree.remove",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
}
],
"summary": "Remove worktree",
"description": "Remove a git worktree and delete its branch.",
"responses": {
"200": {
"description": "Worktree removed",
"content": {
"application/json": {
"schema": {
"type": "boolean"
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BadRequestError"
}
}
}
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WorktreeRemoveInput"
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.remove({\n ...\n})"
}
]
}
},
"/experimental/worktree/reset": {
"post": {
"operationId": "worktree.reset",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
}
],
"summary": "Reset worktree",
"description": "Reset a worktree branch to the primary default branch.",
"responses": {
"200": {
"description": "Worktree reset",
"content": {
"application/json": {
"schema": {
"type": "boolean"
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BadRequestError"
}
}
}
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WorktreeResetInput"
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.reset({\n ...\n})"
}
]
}
},
"/experimental/resource": {
@@ -5692,6 +5799,9 @@
"url": {
"type": "string"
},
"override": {
"type": "string"
},
"color": {
"type": "string"
}
@@ -9988,6 +10098,24 @@
}
}
},
"WorktreeRemoveInput": {
"type": "object",
"properties": {
"directory": {
"type": "string"
}
},
"required": ["directory"]
},
"WorktreeResetInput": {
"type": "object",
"properties": {
"directory": {
"type": "string"
}
},
"required": ["directory"]
},
"McpResource": {
"type": "object",
"properties": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.1.25",
"version": "1.1.26",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.1.25",
"version": "1.1.26",
"type": "module",
"license": "MIT",
"exports": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512"><svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" fill="#131010"></rect>
<path d="M320 224V352H192V224H320Z" fill="#5A5858"></path>
<path fill-rule="evenodd" clip-rule="evenodd" d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z" fill="white"></path>
</svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
@media (prefers-color-scheme: dark) { :root { filter: none; } }
</style></svg>

After

Width:  |  Height:  |  Size: 612 B

+8 -2
View File
@@ -118,9 +118,15 @@
outline: none;
}
}
}
&:focus-visible {
outline: none;
&[data-fit] {
[data-slot="dialog-container"] {
height: auto;
[data-slot="dialog-content"] {
min-height: 0;
}
}
}
}
+2 -1
View File
@@ -8,11 +8,12 @@ export interface DialogProps extends ParentProps {
action?: JSXElement
class?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
fit?: boolean
}
export function Dialog(props: DialogProps) {
return (
<div data-component="dialog">
<div data-component="dialog" data-fit={props.fit ? true : undefined}>
<div data-slot="dialog-container">
<Kobalte.Content
data-slot="dialog-content"
@@ -11,6 +11,11 @@
z-index: 50;
transform-origin: var(--kb-menu-content-transform-origin);
&:focus,
&:focus-visible {
outline: none;
}
&[data-closed] {
animation: dropdown-menu-close 0.15s ease-out;
}
+3 -3
View File
@@ -3,9 +3,9 @@ import { Link, Meta } from "@solidjs/meta"
export const Favicon = () => {
return (
<>
<Link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<Link rel="shortcut icon" href="/favicon.ico" />
<Link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<Link rel="icon" type="image/png" href="/favicon-96x96-v2.png" sizes="96x96" />
<Link rel="shortcut icon" href="/favicon-v2.ico" />
<Link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v2.png" />
<Link rel="manifest" href="/site.webmanifest" />
<Meta name="apple-mobile-web-app-title" content="OpenCode" />
</>
+4 -1
View File
@@ -1,5 +1,7 @@
[data-slot="hover-card-trigger"] {
display: inline-flex;
display: flex;
width: 100%;
min-width: 0;
}
[data-component="hover-card-content"] {
@@ -8,6 +10,7 @@
max-width: 320px;
border-radius: var(--radius-md);
background-color: var(--surface-raised-stronger-non-alpha);
pointer-events: auto;
border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent);
background-clip: padding-box;
+2
View File
@@ -64,6 +64,8 @@ const icons = {
help: `<path d="M7.91683 7.91927V6.2526H12.0835V8.7526L10.0002 10.0026V12.0859M10.0002 13.7526V13.7609M17.9168 10.0026C17.9168 14.3749 14.3724 17.9193 10.0002 17.9193C5.62791 17.9193 2.0835 14.3749 2.0835 10.0026C2.0835 5.63035 5.62791 2.08594 10.0002 2.08594C14.3724 2.08594 17.9168 5.63035 17.9168 10.0026Z" stroke="currentColor" stroke-linecap="square"/>`,
"settings-gear": `<path d="M7.62516 4.46094L5.05225 3.86719L3.86475 5.05469L4.4585 7.6276L2.0835 9.21094V10.7943L4.4585 12.3776L3.86475 14.9505L5.05225 16.138L7.62516 15.5443L9.2085 17.9193H10.7918L12.3752 15.5443L14.9481 16.138L16.1356 14.9505L15.5418 12.3776L17.9168 10.7943V9.21094L15.5418 7.6276L16.1356 5.05469L14.9481 3.86719L12.3752 4.46094L10.7918 2.08594H9.2085L7.62516 4.46094Z" stroke="currentColor"/><path d="M12.5002 10.0026C12.5002 11.3833 11.3809 12.5026 10.0002 12.5026C8.61945 12.5026 7.50016 11.3833 7.50016 10.0026C7.50016 8.62189 8.61945 7.5026 10.0002 7.5026C11.3809 7.5026 12.5002 8.62189 12.5002 10.0026Z" stroke="currentColor"/>`,
dash: `<rect x="5" y="9.5" width="10" height="1" fill="currentColor"/>`,
"cloud-upload": `<path d="M12.0833 16.25H15C17.0711 16.25 18.75 14.5711 18.75 12.5C18.75 10.5649 17.2843 8.97217 15.4025 8.77133C15.2 6.13103 12.8586 4.08333 10 4.08333C7.71532 4.08333 5.76101 5.49781 4.96501 7.49881C2.84892 7.90461 1.25 9.76559 1.25 11.6667C1.25 13.9813 3.30203 16.25 5.83333 16.25H7.91667M10 16.25V10.4167M12.0833 11.875L10 9.79167L7.91667 11.875" stroke="currentColor" stroke-linecap="square"/>`,
trash: `<path d="M4.58342 17.9134L4.58369 17.4134L4.22787 17.5384L4.22766 18.0384H4.58342V17.9134ZM15.4167 17.9134V18.0384H15.7725L15.7723 17.5384L15.4167 17.9134ZM2.08342 3.95508V3.45508H1.58342V3.95508H2.08342V4.45508V3.95508ZM17.9167 4.45508V4.95508H18.4167V4.45508H17.9167V3.95508V4.45508ZM4.16677 4.58008L3.66701 4.5996L4.22816 17.5379L4.72792 17.4934L5.22767 17.4489L4.66652 4.54055L4.16677 4.58008ZM4.58342 18.0384V17.9134H15.4167V18.0384V18.5384H4.58342V18.0384ZM15.4167 17.9134L15.8332 17.5379L16.2498 4.5996L15.7501 4.58008L15.2503 4.56055L14.8337 17.4989L15.4167 17.9134ZM15.8334 4.58008V4.08008H4.16677V4.58008V5.08008H15.8334V4.58008ZM2.08342 4.45508V4.95508H4.16677V4.58008V4.08008H2.08342V4.45508ZM15.8334 4.58008V5.08008H17.9167V4.45508V3.95508H15.8334V4.58008ZM6.83951 4.35149L7.432 4.55047C7.79251 3.47701 8.80699 2.70508 10.0001 2.70508V2.20508V1.70508C8.25392 1.70508 6.77335 2.83539 6.24702 4.15251L6.83951 4.35149ZM10.0001 2.20508V2.70508C11.1932 2.70508 12.2077 3.47701 12.5682 4.55047L13.1607 4.35149L13.7532 4.15251C13.2269 2.83539 11.7463 1.70508 10.0001 1.70508V2.20508Z" fill="currentColor"/>`,
}
export interface IconProps extends ComponentProps<"svg"> {
+37 -7
View File
@@ -35,10 +35,31 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
mouseActive: false,
})
const scrollIntoView = (container: HTMLDivElement, node: HTMLElement, block: "center" | "nearest") => {
const containerRect = container.getBoundingClientRect()
const nodeRect = node.getBoundingClientRect()
const top = nodeRect.top - containerRect.top + container.scrollTop
const bottom = top + nodeRect.height
const viewTop = container.scrollTop
const viewBottom = viewTop + container.clientHeight
const target =
block === "center"
? top - container.clientHeight / 2 + nodeRect.height / 2
: top < viewTop
? top
: bottom > viewBottom
? bottom - container.clientHeight
: viewTop
const max = Math.max(0, container.scrollHeight - container.clientHeight)
container.scrollTop = Math.max(0, Math.min(target, max))
}
const { filter, grouped, flat, active, setActive, onKeyDown, onInput } = useFilteredList<T>(props)
const searchProps = () => (typeof props.search === "object" ? props.search : {})
const moved = (event: MouseEvent) => event.movementX !== 0 || event.movementY !== 0
createEffect(() => {
if (props.filter !== undefined) {
onInput(props.filter)
@@ -65,24 +86,31 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
)
createEffect(() => {
if (!scrollRef()) return
const scroll = scrollRef()
if (!scroll) return
if (!props.current) return
const key = props.key(props.current)
requestAnimationFrame(() => {
const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(key)}"]`)
element?.scrollIntoView({ block: "center" })
const element = scroll.querySelector(`[data-key="${CSS.escape(key)}"]`)
if (!(element instanceof HTMLElement)) return
scrollIntoView(scroll, element, "center")
})
})
createEffect(() => {
const all = flat()
if (store.mouseActive || all.length === 0) return
const scroll = scrollRef()
if (!scroll) return
if (active() === props.key(all[0])) {
scrollRef()?.scrollTo(0, 0)
scroll.scrollTo(0, 0)
return
}
const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(active()!)}"]`)
element?.scrollIntoView({ block: "center" })
const key = active()
if (!key) return
const element = scroll.querySelector(`[data-key="${CSS.escape(key)}"]`)
if (!(element instanceof HTMLElement)) return
scrollIntoView(scroll, element, "center")
})
createEffect(() => {
@@ -201,11 +229,13 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
data-selected={item === props.current}
onClick={() => handleSelect(item, i())}
type="button"
onMouseMove={() => {
onMouseMove={(event) => {
if (!moved(event)) return
setStore("mouseActive", true)
setActive(props.key(item))
}}
onMouseLeave={() => {
if (!store.mouseActive) return
setActive(null)
}}
>

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