Compare commits

..

15 Commits

Author SHA1 Message Date
Adam
bdc8789d8d fix(ui): refactor line selection and search diffs safely 2026-02-26 11:17:07 -06:00
adamelmore
e842c1e7f1 fix(app): restore scroll 2026-02-26 08:57:20 -06:00
David Hill
e9baebeec3 fix(ui): adjust line comment actions padding 2026-02-26 14:44:34 +00:00
David Hill
034cb77d96 fix(ui): update hover comment button color 2026-02-26 14:24:37 +00:00
David Hill
67a22ff7fb fix(ui): improve line comment hover and popover layout 2026-02-26 14:21:37 +00:00
adamelmore
619201422e fix(app): search diffs 2026-02-26 06:52:47 -06:00
adamelmore
7bd77ec242 fix(app): search diffs 2026-02-26 06:44:55 -06:00
adamelmore
a23bdf1acd chore: tests 2026-02-25 10:35:08 -06:00
adamelmore
cb459df2e1 chore: refactor diff and code into file component 2026-02-25 09:25:25 -06:00
adamelmore
abe63a0dab wip: progress 2026-02-25 09:25:24 -06:00
adamelmore
46762ae51f chore: cleanup 2026-02-25 09:25:24 -06:00
adamelmore
baedbc3a79 chore: cleanup 2026-02-25 09:25:24 -06:00
Adam
98d318d096 wip(app): better diff/code comments 2026-02-25 09:25:24 -06:00
Adam
9dcc2b8ed7 wip(app): better diff/code comments 2026-02-25 09:25:23 -06:00
Adam
6741fe6a76 wip(app): better diff/code comments 2026-02-25 09:25:22 -06:00
250 changed files with 6826 additions and 10315 deletions

View File

@@ -1,5 +1,10 @@
name: "Setup Bun"
description: "Setup Bun with caching and install dependencies"
inputs:
cross-compile:
description: "Pre-cache canary cross-compile binaries for all targets"
required: false
default: "false"
runs:
using: "composite"
steps:
@@ -16,13 +21,12 @@ runs:
shell: bash
run: |
if [ "$RUNNER_ARCH" = "X64" ]; then
V=$(node -p "require('./package.json').packageManager.split('@')[1]")
case "$RUNNER_OS" in
macOS) OS=darwin ;;
Linux) OS=linux ;;
Windows) OS=windows ;;
esac
echo "url=https://github.com/oven-sh/bun/releases/download/bun-v${V}/bun-${OS}-x64-baseline.zip" >> "$GITHUB_OUTPUT"
echo "url=https://github.com/oven-sh/bun/releases/download/canary/bun-${OS}-x64-baseline.zip" >> "$GITHUB_OUTPUT"
fi
- name: Setup Bun
@@ -31,6 +35,54 @@ runs:
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
bun-download-url: ${{ steps.bun-url.outputs.url }}
- name: Pre-cache canary cross-compile binaries
if: inputs.cross-compile == 'true'
shell: bash
run: |
BUN_VERSION=$(bun --revision)
if echo "$BUN_VERSION" | grep -q "canary"; then
SEMVER=$(echo "$BUN_VERSION" | sed 's/^\([0-9]*\.[0-9]*\.[0-9]*\).*/\1/')
echo "Bun version: $BUN_VERSION (semver: $SEMVER)"
CACHE_DIR="$HOME/.bun/install/cache"
mkdir -p "$CACHE_DIR"
TMP_DIR=$(mktemp -d)
for TARGET in linux-aarch64 linux-x64 linux-x64-baseline linux-aarch64-musl linux-x64-musl linux-x64-musl-baseline darwin-aarch64 darwin-x64 windows-x64 windows-x64-baseline; do
DEST="$CACHE_DIR/bun-${TARGET}-v${SEMVER}"
if [ -f "$DEST" ]; then
echo "Already cached: $DEST"
continue
fi
URL="https://github.com/oven-sh/bun/releases/download/canary/bun-${TARGET}.zip"
echo "Downloading $TARGET from $URL"
if curl -sfL -o "$TMP_DIR/bun.zip" "$URL"; then
unzip -qo "$TMP_DIR/bun.zip" -d "$TMP_DIR"
if echo "$TARGET" | grep -q "windows"; then
BIN_NAME="bun.exe"
else
BIN_NAME="bun"
fi
mv "$TMP_DIR/bun-${TARGET}/$BIN_NAME" "$DEST"
chmod +x "$DEST"
rm -rf "$TMP_DIR/bun-${TARGET}" "$TMP_DIR/bun.zip"
echo "Cached: $DEST"
# baseline bun resolves "bun-darwin-x64" to the baseline cache key
# so copy the modern binary there too
if [ "$TARGET" = "darwin-x64" ]; then
BASELINE_DEST="$CACHE_DIR/bun-darwin-x64-baseline-v${SEMVER}"
if [ ! -f "$BASELINE_DEST" ]; then
cp "$DEST" "$BASELINE_DEST"
echo "Cached (baseline alias): $BASELINE_DEST"
fi
fi
else
echo "Skipped: $TARGET (not available)"
fi
done
rm -rf "$TMP_DIR"
else
echo "Not a canary build ($BUN_VERSION), skipping pre-cache"
fi
- name: Install dependencies
run: bun install
shell: bash

View File

@@ -65,9 +65,9 @@ jobs:
"packages/web/src/content/docs/*/*.mdx": "allow",
".opencode": "allow",
".opencode/agent": "allow",
".opencode/glossary": "allow",
".opencode/agent/glossary": "allow",
".opencode/agent/translator.md": "allow",
".opencode/glossary/*.md": "allow"
".opencode/agent/glossary/*.md": "allow"
},
"edit": {
"*": "deny",
@@ -76,7 +76,7 @@ jobs:
"glob": {
"*": "deny",
"packages/web/src/content/docs*": "allow",
".opencode/glossary*": "allow"
".opencode/agent/glossary*": "allow"
},
"task": {
"*": "deny",
@@ -90,7 +90,7 @@ jobs:
"read": {
"*": "deny",
".opencode/agent/translator.md": "allow",
".opencode/glossary/*.md": "allow"
".opencode/agent/glossary/*.md": "allow"
}
}
}

View File

@@ -77,6 +77,8 @@ jobs:
fetch-tags: true
- uses: ./.github/actions/setup-bun
with:
cross-compile: "true"
- name: Setup git committer
id: committer
@@ -88,7 +90,7 @@ jobs:
- name: Build
id: build
run: |
./packages/opencode/script/build.ts
./packages/opencode/script/build.ts --all
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}

View File

@@ -20,10 +20,12 @@ jobs:
fetch-tags: true
- uses: ./.github/actions/setup-bun
with:
cross-compile: "true"
- name: Build
run: |
./packages/opencode/script/build.ts
./packages/opencode/script/build.ts --all
- name: Upload unsigned Windows CLI
id: upload_unsigned_windows_cli

View File

@@ -13,7 +13,7 @@ Requirements:
- Preserve meaning, intent, tone, and formatting (including Markdown/MDX structure).
- Preserve all technical terms and artifacts exactly: product/company names, API names, identifiers, code, commands/flags, file paths, URLs, versions, error messages, config keys/values, and anything inside inline code or code blocks.
- Also preserve every term listed in the Do-Not-Translate glossary below.
- Also apply locale-specific guidance from `.opencode/glossary/<locale>.md` when available (for example, `zh-cn.md`).
- Also apply locale-specific guidance from `.opencode/agent/glossary/<locale>.md` when available (for example, `zh-cn.md`).
- Do not modify fenced code blocks.
- Output ONLY the translation (no commentary).

View File

@@ -25,7 +25,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.2.15",
"version": "1.2.14",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -75,7 +75,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.2.15",
"version": "1.2.14",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -109,7 +109,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.2.15",
"version": "1.2.14",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -136,7 +136,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.2.15",
"version": "1.2.14",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -160,7 +160,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.2.15",
"version": "1.2.14",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -184,7 +184,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.2.15",
"version": "1.2.14",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -217,7 +217,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.2.15",
"version": "1.2.14",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -246,7 +246,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.2.15",
"version": "1.2.14",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -262,7 +262,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.2.15",
"version": "1.2.14",
"bin": {
"opencode": "./bin/opencode",
},
@@ -304,8 +304,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.83",
"@opentui/solid": "0.1.83",
"@opentui/core": "0.1.81",
"@opentui/solid": "0.1.81",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -342,7 +342,6 @@
"ulid": "catalog:",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",
@@ -365,7 +364,6 @@
"@types/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.12-a5629fb",
@@ -378,7 +376,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.2.15",
"version": "1.2.14",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -398,7 +396,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.2.15",
"version": "1.2.14",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -409,7 +407,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.2.15",
"version": "1.2.14",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -422,7 +420,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.2.15",
"version": "1.2.14",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -464,7 +462,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.2.15",
"version": "1.2.14",
"dependencies": {
"zod": "catalog:",
},
@@ -475,7 +473,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.2.15",
"version": "1.2.14",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1316,21 +1314,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.83", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.83", "@opentui/core-darwin-x64": "0.1.83", "@opentui/core-linux-arm64": "0.1.83", "@opentui/core-linux-x64": "0.1.83", "@opentui/core-win32-arm64": "0.1.83", "@opentui/core-win32-x64": "0.1.83", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-8r0YoYe1gMXGg6h2M7xadHTI/DD+7PZccK+SgMbpFlLNiH2U/meiGCuDkjxl6N0uyiFHezEFJiCfXAM7u0Z45g=="],
"@opentui/core": ["@opentui/core@0.1.81", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.81", "@opentui/core-darwin-x64": "0.1.81", "@opentui/core-linux-arm64": "0.1.81", "@opentui/core-linux-x64": "0.1.81", "@opentui/core-win32-arm64": "0.1.81", "@opentui/core-win32-x64": "0.1.81", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ooFjkkQ80DDC4X5eLvH8dBcLAtWwGp9RTaWsaeWet3GOv4N0SDcN8mi1XGhYnUlTuxmofby5eQrPegjtWHODlA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.83", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wU3/5pzXQajuxYfsZOA+3/Mywocmu4S1PPEqW9l3PVm9/MGaCWsPq4p9xUuNV4ukOPQhs42TyIHpYpdRleA1sA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.81", "", { "os": "darwin", "cpu": "arm64" }, "sha512-I3Ry5JbkSQXs2g1me8yYr0v3CUcIIfLHzbWz9WMFla8kQDSa+HOr8IpZbqZDeIFgOVzolAXBmZhg0VJI3bZ7MA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.83", "", { "os": "darwin", "cpu": "x64" }, "sha512-xyxqRKxMFCJDJ4sANajhDaznACXPoRBreAZsDAAb/cLirqKdJzQ7gVmInwOwAURTWp/lDe/ryC7FLD9e3V/C9Q=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.81", "", { "os": "darwin", "cpu": "x64" }, "sha512-CrtNKu41D6+bOQdUOmDX4Q3hTL6p+sT55wugPzbDq7cdqFZabCeguBAyOlvRl2g2aJ93kmOWW6MXG0bPPklEFg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.83", "", { "os": "linux", "cpu": "arm64" }, "sha512-9KdFjks7TYGgyGHpuKO1hBiAKEGVNkd686L8GyoVJFHfIKdTIqfnaOUhVTwTiH0WcBT6A7VP/HjAaJqEZKyd6w=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.81", "", { "os": "linux", "cpu": "arm64" }, "sha512-FJw9zmJop9WiMvtT07nSrfBLPLqskxL6xfV3GNft0mSYV+C3hdJ0qkiczGSHUX/6V7fmouM84RWwmY53Rb6hYQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.83", "", { "os": "linux", "cpu": "x64" }, "sha512-rLAGX/fAnpTPylNaMCY1HySe8xFJdXyJXPmDdfsT5VuilIn10YJ+OncrJrq+vYk52avU+AJfnPly4M4lsWGRpw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.81", "", { "os": "linux", "cpu": "x64" }, "sha512-Rj2AFIiuWI0BEMIvh/Jeuxty9Gp5ZhLuQU7ZHJJhojKo/mpBpMs9X+5kwZPZya/tyR8uVDAVyB6AOLkhdRW5lw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.83", "", { "os": "win32", "cpu": "arm64" }, "sha512-m0r3PNkogwzQS0GhL2HvyCbcRgcmEz2NjDRS1GpPGeEKQKfLHBpfwG8GQ+wtrnm7C1LSlcN+1rPXpYgq2FNvJA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.81", "", { "os": "win32", "cpu": "arm64" }, "sha512-AiZB+mZ1cVr8plAPrPT98e3kw6D0OdOSe2CQYLgJRbfRlPqq3jl26lHPzDb3ZO2OR0oVGRPJvXraus939mvoiQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.83", "", { "os": "win32", "cpu": "x64" }, "sha512-d4ltQln60iyy+DS0WRKzUqZ4QCVzO61+jaEWJPfsiNMBstjxlkmqXuV8/gLCHMPKCpy7RDtGiWGDOpGSLTsZuA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.81", "", { "os": "win32", "cpu": "x64" }, "sha512-l8R2Ni1CR4eHi3DTmSkEL/EjHAtOZ/sndYs3VVw+Ej2esL3Mf0W7qSO5S0YNBanz2VXZhbkmM6ERm9keH8RD3w=="],
"@opentui/solid": ["@opentui/solid@0.1.83", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.83", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-82Xe7BvvR0+7pXNKBtnJXHE63skAv7We0X1oqV28KiXvFIBXXJ+Hj2y0Q/DmSWxatRt6ApNREXmiI/TzzJbHBA=="],
"@opentui/solid": ["@opentui/solid@0.1.81", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.81", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-QRjS0wPuIhBRdY8tpG3yprCM4ZnOxWWHTuaZ4hhia2wFZygf7Ome6EuZnLXmtuOQjkjCwu0if8Yik6toc6QylA=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1980,8 +1978,6 @@
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
"@types/which": ["@types/which@3.0.4", "", {}, "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="],
@@ -2946,7 +2942,7 @@
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="],
@@ -4114,7 +4110,7 @@
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
@@ -4702,8 +4698,6 @@
"condense-newlines/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"dot-prop/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="],
@@ -5260,8 +5254,6 @@
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"editorconfig/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],

View File

@@ -8,7 +8,6 @@ import type { Context as GitHubContext } from "@actions/github/lib/context"
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { spawn } from "node:child_process"
import { setTimeout as sleep } from "node:timers/promises"
type GitHubAuthor = {
login: string
@@ -282,7 +281,7 @@ async function assertOpencodeConnected() {
connected = true
break
} catch (e) {}
await sleep(300)
await Bun.sleep(300)
} while (retry++ < 30)
if (!connected) {

View File

@@ -101,7 +101,7 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
})
const zenLiteProduct = new stripe.Product("ZenLite", {
name: "OpenCode Go",
name: "OpenCode Lite",
})
const zenLitePrice = new stripe.Price("ZenLitePrice", {
product: zenLiteProduct.id,

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-vjczRoKikZKRTrrPUQqe6juiPWNSmrTm6BsTzosLehs=",
"aarch64-linux": "sha256-zdc2pFAjoNsMxfY4cyoMQB2Pnd0gBNYywGoV3LkRtbw=",
"aarch64-darwin": "sha256-zbDWIAuuUgAn8bcxmxDmogZfUTlOqQ2dz159jZ5g7Ww=",
"x86_64-darwin": "sha256-5bQ34yE3kW8NqFbzwyx6/Y/t7xPQxFbEXqnz4Cuhh4o="
"x86_64-linux": "sha256-3hfy6nfEnGq4J6inH0pXANw05oas+81iuayn7J0pj9c=",
"aarch64-linux": "sha256-dxWaLtzSeI5NfHwB6u0K10yxoA0ESz/r+zTEQ3FdKFY=",
"aarch64-darwin": "sha256-kkK4rj4g0j2jJFXVmVH7CJcXlI8Dj/KmL/VC3iE4Z+8=",
"x86_64-darwin": "sha256-jt51irxZd48kb0BItd8InP7lfsELUh0unVYO2es+a98="
}
}

View File

@@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.10",
"packageManager": "bun@1.3.9",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev",

View File

@@ -43,7 +43,7 @@ test("file tree can expand folders and open a file", async ({ page, gotoSession
await tab.click()
await expect(tab).toHaveAttribute("aria-selected", "true")
const code = page.locator('[data-component="code"]').first()
await expect(code).toBeVisible()
await expect(code).toContainText("export default function FileTree")
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await expect(viewer).toContainText("export default function FileTree")
})

View File

@@ -43,7 +43,7 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
await expect(tab).toBeVisible()
await tab.click()
const code = page.locator('[data-component="code"]').first()
await expect(code).toBeVisible()
await expect(code.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await expect(viewer.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
})

View File

@@ -9,7 +9,7 @@ import {
sessionIDFromUrl,
} from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk, dirSlug, sessionPath } from "../utils"
import { createSdk, dirSlug } from "../utils"
function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
@@ -51,6 +51,7 @@ test("switching back to a project opens the latest workspace session", async ({
const other = await createTestProject()
const otherSlug = dirSlug(other)
const stamp = Date.now()
let rootDir: string | undefined
let workspaceDir: string | undefined
let sessionID: string | undefined
@@ -79,7 +80,6 @@ test("switching back to a project opens the latest workspace session", async ({
const workspaceSlug = slugFromUrl(page.url())
workspaceDir = base64Decode(workspaceSlug)
if (!workspaceDir) throw new Error(`Failed to decode workspace slug: ${workspaceSlug}`)
await openSidebar(page)
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
@@ -92,14 +92,15 @@ test("switching back to a project opens the latest workspace session", async ({
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
const created = await createSdk(workspaceDir)
.session.create()
.then((x) => x.data?.id)
if (!created) throw new Error(`Failed to create session for workspace: ${workspaceDir}`)
sessionID = created
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.fill(`project switch remembers workspace ${stamp}`)
await prompt.press("Enter")
await page.goto(sessionPath(workspaceDir, created))
await expect(page.locator(promptSelector)).toBeVisible()
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to parse session id from URL: ${page.url()}`)
sessionID = created
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
await openSidebar(page)
@@ -113,8 +114,7 @@ test("switching back to a project opens the latest workspace session", async ({
await expect(rootButton).toBeVisible()
await rootButton.click()
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
},
{ extra: [other] },
)

View File

@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
import { clearSessionDockSeed, seedSessionPermission, seedSessionQuestion, seedSessionTodos } from "../actions"
import {
permissionDockSelector,
promptSelector,
@@ -11,23 +11,11 @@ import {
} from "../selectors"
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
async function withDockSession<T>(
sdk: Sdk,
title: string,
fn: (session: { id: string; title: string }) => Promise<T>,
opts?: { permission?: PermissionRule[] },
) {
const session = await sdk.session
.create(opts?.permission ? { title, permission: opts.permission } : { title })
.then((r) => r.data)
async function withDockSession<T>(sdk: Sdk, title: string, fn: (session: { id: string; title: string }) => Promise<T>) {
const session = await sdk.session.create({ title }).then((r) => r.data)
if (!session?.id) throw new Error("Session create did not return an id")
try {
return await fn(session)
} finally {
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
}
return fn(session)
}
test.setTimeout(120_000)
@@ -40,85 +28,6 @@ async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>
}
}
async function clearPermissionDock(page: any, label: RegExp) {
const dock = page.locator(permissionDockSelector)
for (let i = 0; i < 3; i++) {
const count = await dock.count()
if (count === 0) return
await dock.getByRole("button", { name: label }).click()
await page.waitForTimeout(150)
}
}
async function withMockPermission<T>(
page: any,
request: {
id: string
sessionID: string
permission: string
patterns: string[]
metadata?: Record<string, unknown>
always?: string[]
},
opts: { child?: any } | undefined,
fn: () => Promise<T>,
) {
let pending = [
{
...request,
always: request.always ?? ["*"],
metadata: request.metadata ?? {},
},
]
const list = async (route: any) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(pending),
})
}
const reply = async (route: any) => {
const url = new URL(route.request().url())
const id = url.pathname.split("/").pop()
pending = pending.filter((item) => item.id !== id)
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(true),
})
}
await page.route("**/permission", list)
await page.route("**/session/*/permissions/*", reply)
const sessionList = opts?.child
? async (route: any) => {
const res = await route.fetch()
const json = await res.json()
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
await route.fulfill({
status: res.status(),
headers: res.headers(),
contentType: "application/json",
body: JSON.stringify(json),
})
}
: undefined
if (sessionList) await page.route("**/session?*", sessionList)
try {
return await fn()
} finally {
await page.unroute("**/permission", list)
await page.unroute("**/session/*/permissions/*", reply)
if (sessionList) await page.unroute("**/session?*", sessionList)
}
}
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock default", async (session) => {
await gotoSession(session.id)
@@ -167,175 +76,72 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
await gotoSession(session.id)
await withMockPermission(
page,
{
id: "per_e2e_once",
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionPermission(sdk, {
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-once"],
metadata: { description: "Need permission for command" },
},
undefined,
async () => {
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
patterns: ["README.md"],
description: "Need permission for command",
})
await clearPermissionDock(page, /allow once/i)
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
},
)
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await page
.locator(permissionDockSelector)
.getByRole("button", { name: /allow once/i })
.click()
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
await gotoSession(session.id)
await withMockPermission(
page,
{
id: "per_e2e_reject",
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionPermission(sdk, {
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-reject"],
},
undefined,
async () => {
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
patterns: ["REJECT.md"],
})
await clearPermissionDock(page, /deny/i)
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
},
)
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await page.locator(permissionDockSelector).getByRole("button", { name: /deny/i }).click()
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
await gotoSession(session.id)
await withMockPermission(
page,
{
id: "per_e2e_always",
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionPermission(sdk, {
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-always"],
metadata: { description: "Need permission for command" },
},
undefined,
async () => {
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await clearPermissionDock(page, /allow always/i)
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
},
)
})
})
test("child session question request blocks parent dock and unblocks after submit", async ({
page,
sdk,
gotoSession,
}) => {
await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
await gotoSession(session.id)
const child = await sdk.session
.create({
title: "e2e composer dock child question",
parentID: session.id,
patterns: ["README.md"],
description: "Need permission for command",
})
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
try {
await withDockSeed(sdk, child.id, async () => {
await seedSessionQuestion(sdk, {
sessionID: child.id,
questions: [
{
header: "Child input",
question: "Pick one child option",
options: [
{ label: "Continue", description: "Continue child" },
{ label: "Stop", description: "Stop child" },
],
},
],
})
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
const dock = page.locator(questionDockSelector)
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
} finally {
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
}
})
})
test("child session permission request blocks parent dock and supports allow once", async ({
page,
sdk,
gotoSession,
}) => {
await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
await gotoSession(session.id)
const child = await sdk.session
.create({
title: "e2e composer dock child permission",
parentID: session.id,
})
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
try {
await withMockPermission(
page,
{
id: "per_e2e_child",
sessionID: child.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-child"],
metadata: { description: "Need child permission" },
},
{ child },
async () => {
await page.goto(page.url())
const dock = page.locator(permissionDockSelector)
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await clearPermissionDock(page, /allow once/i)
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
},
)
} finally {
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
}
await page
.locator(permissionDockSelector)
.getByRole("button", { name: /allow always/i })
.click()
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.15",
"version": "1.2.14",
"description": "",
"type": "module",
"exports": {

View File

@@ -1,11 +1,9 @@
import "@/index.css"
import { Code } from "@opencode-ai/ui/code"
import { File } from "@opencode-ai/ui/file"
import { I18nProvider } from "@opencode-ai/ui/context"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { Diff } from "@opencode-ai/ui/diff"
import { Font } from "@opencode-ai/ui/font"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { MetaProvider } from "@solidjs/meta"
@@ -122,9 +120,7 @@ export function AppBaseProviders(props: ParentProps) {
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider>
<MarkedProviderWithNativeParser>
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
</DiffComponentProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProviderWithNativeParser>
</DialogProvider>
</ErrorBoundary>

View File

@@ -97,20 +97,9 @@ export const DialogSelectModelUnpaid: Component = () => {
<div class="w-full flex items-center gap-x-3">
<ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
</Show>
<Show when={i.id === "opencode"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={i.id === "opencode-go"}>
<>
<div class="text-14-regular text-text-weak">
{language.t("dialog.provider.opencodeGo.tagline")}
</div>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</>
</Show>
<Show when={i.id === "anthropic"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
</Show>

View File

@@ -29,7 +29,6 @@ export const DialogSelectProvider: Component = () => {
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
if (id === "openai") return language.t("dialog.provider.openai.note")
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline")
}
return (
@@ -71,9 +70,6 @@ export const DialogSelectProvider: Component = () => {
<div class="px-1.25 w-full flex items-center gap-x-3">
<ProviderIcon data-slot="list-item-extra-icon" id={icon(i.id)} />
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
</Show>
<Show when={i.id === CUSTOM_ID}>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</Show>
@@ -81,9 +77,6 @@ export const DialogSelectProvider: Component = () => {
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
<Show when={i.id === "opencode-go"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
</div>
)}
</List>

View File

@@ -2,7 +2,6 @@ import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
@@ -10,27 +9,32 @@ import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
interface ServerFormProps {
interface AddRowProps {
value: string
placeholder: string
adding: boolean
error: string
status: boolean | undefined
onChange: (value: string) => void
onKeyDown: (event: KeyboardEvent) => void
onBlur: () => void
}
interface EditRowProps {
value: string
name: string
username: string
password: string
placeholder: string
busy: boolean
error: string
status: boolean | undefined
onChange: (value: string) => void
onNameChange: (value: string) => void
onUsernameChange: (value: string) => void
onPasswordChange: (value: string) => void
onSubmit: () => void
onBack: () => void
onKeyDown: (event: KeyboardEvent) => void
onBlur: () => void
}
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
@@ -79,86 +83,83 @@ function useServerPreview(fetcher: typeof fetch) {
return host.includes(".") || host.includes(":")
}
const previewStatus = async (
value: string,
username: string,
password: string,
setStatus: (value: boolean | undefined) => void,
) => {
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
setStatus(undefined)
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const http: ServerConnection.HttpBase = { url: normalized }
if (username) http.username = username
if (password) http.password = password
const result = await checkServerHealth(http, fetcher)
const result = await checkServerHealth({ url: normalized }, fetcher)
setStatus(result.healthy)
}
return { previewStatus }
}
function ServerForm(props: ServerFormProps) {
const language = useLanguage()
const keyDown = (event: KeyboardEvent) => {
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
props.onBack()
return
}
if (event.key !== "Enter" || event.isComposing) return
event.preventDefault()
props.onSubmit()
}
function AddRow(props: AddRowProps) {
return (
<div class="px-5">
<div class="bg-surface-raised-base rounded-md p-5 flex flex-col gap-3">
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
<TextField
type="text"
label={language.t("dialog.server.add.url")}
placeholder={props.placeholder}
value={props.value}
autofocus
validationState={props.error ? "invalid" : "valid"}
error={props.error}
disabled={props.busy}
onChange={props.onChange}
onKeyDown={keyDown}
/>
</div>
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
<div
classList={{
"size-1.5 rounded-full absolute left-3 top-1/2 -translate-y-1/2 z-10 pointer-events-none": true,
"bg-icon-success-base": props.status === true,
"bg-icon-critical-base": props.status === false,
"bg-border-weak-base": props.status === undefined,
}}
ref={(el) => {
// Position relative to input-wrapper
requestAnimationFrame(() => {
const wrapper = el.parentElement?.querySelector('[data-slot="input-wrapper"]')
if (wrapper instanceof HTMLElement) {
wrapper.appendChild(el)
}
})
}}
/>
<TextField
type="text"
label={language.t("dialog.server.add.name")}
placeholder={language.t("dialog.server.add.namePlaceholder")}
value={props.name}
disabled={props.busy}
onChange={props.onNameChange}
onKeyDown={keyDown}
hideLabel
placeholder={props.placeholder}
value={props.value}
autofocus
validationState={props.error ? "invalid" : "valid"}
error={props.error}
disabled={props.adding}
onChange={props.onChange}
onKeyDown={props.onKeyDown}
onBlur={props.onBlur}
class="pl-7"
/>
</div>
</div>
)
}
function EditRow(props: EditRowProps) {
return (
<div class="flex items-center gap-3 px-4 min-w-0 flex-1" onClick={(event) => event.stopPropagation()}>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": props.status === true,
"bg-icon-critical-base": props.status === false,
"bg-border-weak-base": props.status === undefined,
}}
/>
<div class="flex-1 min-w-0">
<TextField
type="text"
hideLabel
placeholder={props.placeholder}
value={props.value}
autofocus
validationState={props.error ? "invalid" : "valid"}
error={props.error}
disabled={props.busy}
onChange={props.onChange}
onKeyDown={props.onKeyDown}
onBlur={props.onBlur}
/>
<div class="grid grid-cols-2 gap-2 min-w-0">
<TextField
type="text"
label={language.t("dialog.server.add.username")}
placeholder="username"
value={props.username}
disabled={props.busy}
onChange={props.onUsernameChange}
onKeyDown={keyDown}
/>
<TextField
type="password"
label={language.t("dialog.server.add.password")}
placeholder="password"
value={props.password}
disabled={props.busy}
onChange={props.onPasswordChange}
onKeyDown={keyDown}
/>
</div>
</div>
</div>
)
@@ -173,13 +174,11 @@ export function DialogSelectServer() {
const fetcher = platform.fetch ?? globalThis.fetch
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
const { previewStatus } = useServerPreview(fetcher)
let listRoot: HTMLDivElement | undefined
const [store, setStore] = createStore({
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
addServer: {
url: "",
name: "",
username: "",
password: "",
adding: false,
error: "",
showForm: false,
@@ -188,9 +187,6 @@ export function DialogSelectServer() {
editServer: {
id: undefined as string | undefined,
value: "",
name: "",
username: "",
password: "",
error: "",
busy: false,
status: undefined as boolean | undefined,
@@ -200,32 +196,27 @@ export function DialogSelectServer() {
const resetAdd = () => {
setStore("addServer", {
url: "",
name: "",
username: "",
password: "",
adding: false,
error: "",
showForm: false,
status: undefined,
})
}
const resetEdit = () => {
setStore("editServer", {
id: undefined,
value: "",
name: "",
username: "",
password: "",
error: "",
status: undefined,
busy: false,
})
}
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
const replaceServer = (original: ServerConnection.Http, next: string) => {
const active = server.key
const newConn = server.add(next)
if (!newConn) return
const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
if (nextActive) server.setActive(nextActive)
server.remove(ServerConnection.key(original))
@@ -280,8 +271,8 @@ export function DialogSelectServer() {
async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
dialog.close()
if (persist && conn.type === "http") {
server.add(conn)
if (persist) {
server.add(conn.http.url)
navigate("/")
return
}
@@ -292,59 +283,21 @@ export function DialogSelectServer() {
const handleAddChange = (value: string) => {
if (store.addServer.adding) return
setStore("addServer", { url: value, error: "" })
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
void previewStatus(value, (next) => setStore("addServer", { status: next }))
}
const handleAddNameChange = (value: string) => {
if (store.addServer.adding) return
setStore("addServer", { name: value, error: "" })
}
const handleAddUsernameChange = (value: string) => {
if (store.addServer.adding) return
setStore("addServer", { username: value, error: "" })
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddPasswordChange = (value: string) => {
if (store.addServer.adding) return
setStore("addServer", { password: value, error: "" })
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
setStore("addServer", { status: next }),
)
const scrollListToBottom = () => {
const scroll = listRoot?.querySelector<HTMLDivElement>('[data-slot="list-scroll"]')
if (!scroll) return
requestAnimationFrame(() => {
scroll.scrollTop = scroll.scrollHeight
})
}
const handleEditChange = (value: string) => {
if (store.editServer.busy) return
setStore("editServer", { value, error: "" })
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditNameChange = (value: string) => {
if (store.editServer.busy) return
setStore("editServer", { name: value, error: "" })
}
const handleEditUsernameChange = (value: string) => {
if (store.editServer.busy) return
setStore("editServer", { username: value, error: "" })
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditPasswordChange = (value: string) => {
if (store.editServer.busy) return
setStore("editServer", { password: value, error: "" })
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
setStore("editServer", { status: next }),
)
void previewStatus(value, (next) => setStore("editServer", { status: next }))
}
async function handleAdd(value: string) {
@@ -357,22 +310,16 @@ export function DialogSelectServer() {
setStore("addServer", { adding: true, error: "" })
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.username) conn.http.username = store.addServer.username
if (store.addServer.password) conn.http.password = store.addServer.password
const result = await checkServerHealth(conn.http, fetcher)
const result = await checkServerHealth({ url: normalized }, fetcher)
setStore("addServer", { adding: false })
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
await select(conn, true)
await select({ type: "http", http: { url: normalized } }, true)
}
async function handleEdit(original: ServerConnection.Any, value: string) {
@@ -383,114 +330,52 @@ export function DialogSelectServer() {
return
}
const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = original.displayName
if (
normalized === original.http.url &&
name === existingName &&
username === original.http.username &&
password === original.http.password
) {
if (normalized === original.http.url) {
resetEdit()
return
}
setStore("editServer", { busy: true, error: "" })
const conn: ServerConnection.Http = {
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http, fetcher)
const result = await checkServerHealth({ url: normalized }, fetcher)
setStore("editServer", { busy: false })
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === original.http.url) {
server.add(conn)
} else {
replaceServer(original, conn)
}
replaceServer(original, normalized)
resetEdit()
}
const mode = createMemo<"list" | "add" | "edit">(() => {
if (store.editServer.id) return "edit"
if (store.addServer.showForm) return "add"
return "list"
})
const editing = createMemo(() => {
if (!store.editServer.id) return
return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
})
const resetForm = () => {
resetAdd()
resetEdit()
const handleAddKey = (event: KeyboardEvent) => {
event.stopPropagation()
if (event.key !== "Enter" || event.isComposing) return
event.preventDefault()
handleAdd(store.addServer.url)
}
const startAdd = () => {
resetEdit()
setStore("addServer", {
showForm: true,
url: "",
name: "",
username: "",
password: "",
error: "",
status: undefined,
})
}
const startEdit = (conn: ServerConnection.Http) => {
resetAdd()
setStore("editServer", {
id: conn.http.url,
value: conn.http.url,
name: conn.displayName ?? "",
username: conn.http.username ?? "",
password: conn.http.password ?? "",
error: "",
status: store.status[ServerConnection.key(conn)]?.healthy,
busy: false,
})
}
const submitForm = () => {
if (mode() === "add") {
void handleAdd(store.addServer.url)
const blurAdd = () => {
if (!store.addServer.url.trim()) {
resetAdd()
return
}
const original = editing()
if (!original) return
void handleEdit(original, store.editServer.value)
handleAdd(store.addServer.url)
}
const isFormMode = createMemo(() => mode() !== "list")
const isAddMode = createMemo(() => mode() === "add")
const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy))
const formTitle = createMemo(() => {
if (!isFormMode()) return language.t("dialog.server.title")
return (
<div class="flex items-center gap-2 -ml-2">
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
</div>
)
})
createEffect(() => {
if (!store.editServer.id) return
if (editing()) return
resetEdit()
})
const handleEditKey = (event: KeyboardEvent, original: ServerConnection.Any) => {
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
resetEdit()
return
}
if (event.key !== "Enter" || event.isComposing) return
event.preventDefault()
handleEdit(original, store.editServer.value)
}
async function handleRemove(url: ServerConnection.Key) {
server.remove(url)
@@ -500,29 +385,9 @@ export function DialogSelectServer() {
}
return (
<Dialog title={formTitle()}>
<Dialog title={language.t("dialog.server.title")}>
<div class="flex flex-col gap-2">
<Show
when={!isFormMode()}
fallback={
<ServerForm
value={isAddMode() ? store.addServer.url : store.editServer.value}
name={isAddMode() ? store.addServer.name : store.editServer.name}
username={isAddMode() ? store.addServer.username : store.editServer.username}
password={isAddMode() ? store.addServer.password : store.editServer.password}
placeholder={language.t("dialog.server.add.placeholder")}
busy={formBusy()}
error={isAddMode() ? store.addServer.error : store.editServer.error}
status={isAddMode() ? store.addServer.status : store.editServer.status}
onChange={isAddMode() ? handleAddChange : handleEditChange}
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
onSubmit={submitForm}
onBack={resetForm}
/>
}
>
<div ref={(el) => (listRoot = el)}>
<List
search={{
placeholder: language.t("dialog.server.search.placeholder"),
@@ -535,110 +400,143 @@ export function DialogSelectServer() {
onSelect={(x) => {
if (x) select(x)
}}
onFilter={(value) => {
if (value && store.addServer.showForm && !store.addServer.adding) {
resetAdd()
}
}}
divider={true}
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
add={
store.addServer.showForm
? {
render: () => (
<AddRow
value={store.addServer.url}
placeholder={language.t("dialog.server.add.placeholder")}
adding={store.addServer.adding}
error={store.addServer.error}
status={store.addServer.status}
onChange={handleAddChange}
onKeyDown={handleAddKey}
onBlur={blurAdd}
/>
),
}
: undefined
}
>
{(i) => {
const key = ServerConnection.key(i)
return (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
<div class="flex flex-col h-full items-start w-5">
<ServerHealthIndicator health={store.status[key]} />
</div>
<ServerRow
conn={i}
dimmed={store.status[key]?.healthy === false}
status={store.status[key]}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={defaultUrl() === i.http.url}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
<Show
when={store.editServer.id !== i.http.url}
fallback={
<EditRow
value={store.editServer.value}
placeholder={language.t("dialog.server.add.placeholder")}
busy={store.editServer.busy}
error={store.editServer.error}
status={store.editServer.status}
onChange={handleEditChange}
onKeyDown={(event) => handleEditKey(event, i)}
onBlur={() => handleEdit(i, store.editServer.value)}
/>
}
showCredentials
/>
<div class="flex items-center justify-center gap-4 pl-4">
<Show when={ServerConnection.key(current()) === key}>
<Icon name="check" class="h-6" />
</Show>
>
<ServerRow
conn={i}
status={store.status[key]}
dimmed={store.status[key]?.healthy === false}
class="flex items-center gap-3 px-4 min-w-0 flex-1"
badge={
<Show when={defaultUrl() === i.http.url}>
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
}
/>
</Show>
<Show when={store.editServer.id !== i.http.url}>
<div class="flex items-center justify-center gap-5 pl-4">
<Show when={ServerConnection.key(current()) === key}>
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
</Show>
<Show when={i.type === "http"}>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
if (i.type !== "http") return
startEdit(i)
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultUrl() !== i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
<Show when={i.type === "http"}>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
setStore("editServer", {
id: i.http.url,
value: i.http.url,
error: "",
status: store.status[ServerConnection.key(i)]?.healthy,
})
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultUrl() !== i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultUrl() === i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
{language.t("dialog.server.menu.delete")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultUrl() === i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Show>
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Show>
</div>
</Show>
</div>
)
}}
</List>
</Show>
</div>
<div class="px-5 pb-5">
<Show
when={isFormMode()}
fallback={
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={startAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{language.t("dialog.server.add.button")}
</Button>
}
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={() => {
setStore("addServer", { showForm: true, url: "", error: "" })
scrollListToBottom()
}}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
<Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5">
{formBusy()
? language.t("dialog.server.add.checking")
: isAddMode()
? language.t("dialog.server.add.button")
: language.t("common.save")}
</Button>
</Show>
{store.addServer.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")}
</Button>
</div>
</div>
</Dialog>

View File

@@ -1,6 +1,5 @@
import { Tooltip } from "@opencode-ai/ui/tooltip"
import {
children,
createEffect,
createMemo,
createSignal,
@@ -10,7 +9,7 @@ import {
type ParentProps,
Show,
} from "solid-js"
import { type ServerConnection, serverName } from "@/context/server"
import { type ServerConnection, serverDisplayName } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health"
interface ServerRowProps extends ParentProps {
@@ -21,14 +20,13 @@ interface ServerRowProps extends ParentProps {
versionClass?: string
dimmed?: boolean
badge?: JSXElement
showCredentials?: boolean
}
export function ServerRow(props: ServerRowProps) {
const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined
const name = createMemo(() => serverName(props.conn))
const name = createMemo(() => serverDisplayName(props.conn))
const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
@@ -54,71 +52,35 @@ export function ServerRow(props: ServerRowProps) {
const tooltipValue = () => (
<span class="flex items-center gap-2">
<span>{serverName(props.conn, true)}</span>
<span>{name()}</span>
<Show when={props.status?.version}>
<span class="text-text-invert-weak">v{props.status?.version}</span>
<span class="text-text-invert-base">{props.status?.version}</span>
</Show>
</span>
)
const badge = children(() => props.badge)
return (
<Tooltip
class="flex-1"
value={tooltipValue()}
placement="top-start"
inactive={!truncated() && !props.conn.displayName}
>
<Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
<div class={props.class} classList={{ "opacity-50": props.dimmed }}>
<div class="flex flex-col items-start">
<div class="flex flex-row items-center gap-2">
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
{name()}
</span>
<Show
when={badge()}
fallback={
<Show when={props.status?.version}>
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
v{props.status?.version}
</span>
</Show>
}
>
{(badge) => badge()}
</Show>
</div>
<Show when={props.showCredentials && props.conn.type === "http" && props.conn}>
{(conn) => (
<div class="flex flex-row gap-3">
<span>
{conn().http.username ? (
<span class="text-text-weak">{conn().http.username}</span>
) : (
<span class="text-text-weaker">no username</span>
)}
</span>
{conn().http.password && <span class="text-text-weak"></span>}
</div>
)}
</Show>
</div>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": props.status?.healthy === true,
"bg-icon-critical-base": props.status?.healthy === false,
"bg-border-weak-base": props.status === undefined,
}}
/>
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
{name()}
</span>
<Show when={props.status?.version}>
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
{props.status?.version}
</span>
</Show>
{props.badge}
{props.children}
</div>
</Tooltip>
)
}
export function ServerHealthIndicator(props: { health?: ServerHealth }) {
return (
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": props.health?.healthy === true,
"bg-icon-critical-base": props.health?.healthy === false,
"bg-border-weak-base": props.health === undefined,
}}
/>
)
}

View File

@@ -9,7 +9,7 @@ import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { Code } from "@opencode-ai/ui/code"
import { File } from "@opencode-ai/ui/file"
import { Markdown } from "@opencode-ai/ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
@@ -47,7 +47,8 @@ function RawMessageContent(props: { message: Message; getParts: (id: string) =>
})
return (
<Code
<File
mode="text"
file={file()}
overflow="wrap"
class="select-text"

View File

@@ -187,22 +187,9 @@ export const SettingsProviders: Component = () => {
<div class="flex items-center gap-x-3">
<ProviderIcon id={icon(item.id)} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{item.name}</span>
<Show when={item.id === "opencode"}>
<span class="text-14-regular text-text-weak">
{language.t("dialog.provider.opencode.tagline")}
</span>
</Show>
<Show when={item.id === "opencode"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={item.id === "opencode-go"}>
<>
<span class="text-14-regular text-text-weak">
{language.t("dialog.provider.opencodeGo.tagline")}
</span>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</>
</Show>
</div>
<Show when={note(item.id)}>
{(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>}

View File

@@ -8,7 +8,7 @@ import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
@@ -276,11 +276,10 @@ export function StatusPopover() {
navigate("/")
}}
>
<ServerHealthIndicator health={health[key]} />
<ServerRow
conn={s}
dimmed={isBlocked()}
status={health[key]}
dimmed={isBlocked()}
class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"

View File

@@ -265,9 +265,6 @@ export function Titlebar() {
</div>
</div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<div class="bg-icon-interactive-base text-background-base font-medium px-2 rounded-sm uppercase font-mono">
BETA
</div>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">

View File

@@ -44,6 +44,17 @@ function aggregate(comments: Record<string, LineComment[]>) {
.sort((a, b) => a.time - b.time)
}
function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
const next: SelectedLineRange = {
start: selection.start,
end: selection.end,
}
if (selection.side) next.side = selection.side
if (selection.endSide) next.endSide = selection.endSide
return next
}
function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
const [state, setState] = createStore({
focus: null as CommentFocus | null,
@@ -70,6 +81,7 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
id: uuid(),
time: Date.now(),
...input,
selection: cloneSelection(input.selection),
}
batch(() => {

View File

@@ -9,7 +9,7 @@ const MAX_FILE_VIEW_SESSIONS = 20
const MAX_VIEW_FILES = 500
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
if (range.start <= range.end) return range
if (range.start <= range.end) return { ...range }
const startSide = range.side
const endSide = range.endSide ?? startSide

View File

@@ -41,4 +41,24 @@ describe("createScrollPersistence", () => {
vi.useRealTimers()
}
})
test("reseeds empty cache after persisted snapshot loads", () => {
const snapshot = {
session: {},
} as Record<string, Record<string, { x: number; y: number }>>
const scroll = createScrollPersistence({
getSnapshot: (sessionKey) => snapshot[sessionKey],
onFlush: () => {},
})
expect(scroll.scroll("session", "review")).toBeUndefined()
snapshot.session = {
review: { x: 12, y: 34 },
}
expect(scroll.scroll("session", "review")).toEqual({ x: 12, y: 34 })
scroll.dispose()
})
})

View File

@@ -33,8 +33,16 @@ export function createScrollPersistence(opts: Options) {
}
function seed(sessionKey: string) {
if (cache[sessionKey]) return
setCache(sessionKey, clone(opts.getSnapshot(sessionKey)))
const next = clone(opts.getSnapshot(sessionKey))
const current = cache[sessionKey]
if (!current) {
setCache(sessionKey, next)
return
}
if (Object.keys(current).length > 0) return
if (Object.keys(next).length === 0) return
setCache(sessionKey, next)
}
function scroll(sessionKey: string, tab: string) {

View File

@@ -6,7 +6,6 @@ import { Persist, persisted } from "@/utils/persist"
import { checkServerHealth } from "@/utils/server-health"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
const HEALTH_POLL_INTERVAL_MS = 10_000
export function normalizeServerUrl(input: string) {
@@ -16,9 +15,9 @@ export function normalizeServerUrl(input: string) {
return withProtocol.replace(/\/+$/, "")
}
export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = false) {
export function serverDisplayName(conn?: ServerConnection.Any) {
if (!conn) return ""
if (conn.displayName && !ignoreDisplayName) return conn.displayName
if (conn.displayName) return conn.displayName
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
}
@@ -101,33 +100,22 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]),
createStore({
list: [] as StoredServer[],
list: [] as string[],
projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>,
}),
)
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
const allServers = createMemo((): Array<ServerConnection.Any> => {
const servers = [
...(props.servers ?? []),
...store.list.map((value) =>
typeof value === "string"
? {
type: "http" as const,
http: { url: value },
}
: value,
),
...store.list.map((value) => ({
type: "http" as const,
http: typeof value === "string" ? { url: value } : value,
})),
]
const deduped = new Map(
servers.map((value) => {
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
return [ServerConnection.key(conn), conn]
}),
)
const deduped = new Map(servers.map((conn) => [ServerConnection.key(conn), conn]))
return [...deduped.values()]
})
@@ -168,29 +156,27 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
if (state.active !== input) setState("active", input)
}
function add(input: ServerConnection.Http) {
const url_ = normalizeServerUrl(input.http.url)
if (!url_) return
const conn = { ...input, http: { ...input.http, url: url_ } }
function add(input: string) {
const url = normalizeServerUrl(input)
if (!url) return
return batch(() => {
const existing = store.list.findIndex((x) => url(x) === url_)
if (existing !== -1) {
setStore("list", existing, conn)
} else {
setStore("list", store.list.length, conn)
const http: ServerConnection.HttpBase = { url }
if (!store.list.includes(url)) {
setStore("list", store.list.length, url)
}
const conn: ServerConnection.Http = { type: "http", http }
setState("active", ServerConnection.key(conn))
return conn
})
}
function remove(key: ServerConnection.Key) {
const list = store.list.filter((x) => url(x) !== key)
const list = store.list.filter((x) => x !== key)
batch(() => {
setStore("list", list)
if (state.active === key) {
const next = list[0]
setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer)
setState("active", next ? ServerConnection.key({ type: "http", http: { url: next } }) : props.defaultServer)
}
})
}
@@ -226,7 +212,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
return state.active
},
get name() {
return serverName(current())
return serverDisplayName(current())
},
get list() {
return allServers()

View File

@@ -3,16 +3,7 @@ import { decode64 } from "@/utils/base64"
import { useParams } from "@solidjs/router"
import { createMemo } from "solid-js"
export const popularProviders = [
"opencode",
"opencode-go",
"anthropic",
"github-copilot",
"openai",
"google",
"openrouter",
"vercel",
]
export const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
const popularProviderSet = new Set(popularProviders)
export function useProviders() {

View File

@@ -91,8 +91,6 @@ export const dict = {
"dialog.provider.group.other": "آخر",
"dialog.provider.tag.recommended": "موصى به",
"dialog.provider.opencode.note": "نماذج مختارة تتضمن Claude و GPT و Gemini والمزيد",
"dialog.provider.opencode.tagline": "نماذج موثوقة ومحسنة",
"dialog.provider.opencodeGo.tagline": "اشتراك منخفض التكلفة للجميع",
"dialog.provider.anthropic.note": "اتصل باستخدام Claude Pro/Max أو مفتاح API",
"dialog.provider.copilot.note": "اتصل باستخدام Copilot أو مفتاح API",
"dialog.provider.openai.note": "اتصل باستخدام ChatGPT Pro/Plus أو مفتاح API",

View File

@@ -91,8 +91,6 @@ export const dict = {
"dialog.provider.group.other": "Outro",
"dialog.provider.tag.recommended": "Recomendado",
"dialog.provider.opencode.note": "Modelos selecionados incluindo Claude, GPT, Gemini e mais",
"dialog.provider.opencode.tagline": "Modelos otimizados e confiáveis",
"dialog.provider.opencodeGo.tagline": "Assinatura de baixo custo para todos",
"dialog.provider.anthropic.note": "Conectar com Claude Pro/Max ou chave de API",
"dialog.provider.copilot.note": "Conectar com Copilot ou chave de API",
"dialog.provider.openai.note": "Conectar com ChatGPT Pro/Plus ou chave de API",

View File

@@ -99,8 +99,6 @@ export const dict = {
"dialog.provider.group.other": "Ostalo",
"dialog.provider.tag.recommended": "Preporučeno",
"dialog.provider.opencode.note": "Kurirani modeli uključujući Claude, GPT, Gemini i druge",
"dialog.provider.opencode.tagline": "Pouzdani optimizovani modeli",
"dialog.provider.opencodeGo.tagline": "Povoljna pretplata za sve",
"dialog.provider.anthropic.note": "Direktan pristup Claude modelima, uključujući Pro i Max",
"dialog.provider.copilot.note": "AI modeli za pomoć pri kodiranju putem GitHub Copilot",
"dialog.provider.openai.note": "GPT modeli za brze, sposobne opšte AI zadatke",

View File

@@ -99,8 +99,6 @@ export const dict = {
"dialog.provider.group.other": "Andre",
"dialog.provider.tag.recommended": "Anbefalet",
"dialog.provider.opencode.note": "Udvalgte modeller inklusive Claude, GPT, Gemini og flere",
"dialog.provider.opencode.tagline": "Pålidelige optimerede modeller",
"dialog.provider.opencodeGo.tagline": "Billigt abonnement for alle",
"dialog.provider.anthropic.note": "Direkte adgang til Claude-modeller, inklusive Pro og Max",
"dialog.provider.copilot.note": "AI-modeller til kodningsassistance via GitHub Copilot",
"dialog.provider.openai.note": "GPT-modeller til hurtige, kompetente generelle AI-opgaver",

View File

@@ -95,8 +95,6 @@ export const dict = {
"dialog.provider.group.other": "Andere",
"dialog.provider.tag.recommended": "Empfohlen",
"dialog.provider.opencode.note": "Kuratierte Modelle inklusive Claude, GPT, Gemini und mehr",
"dialog.provider.opencode.tagline": "Zuverlässige, optimierte Modelle",
"dialog.provider.opencodeGo.tagline": "Kostengünstiges Abo für alle",
"dialog.provider.anthropic.note": "Mit Claude Pro/Max oder API-Schlüssel verbinden",
"dialog.provider.copilot.note": "Mit Copilot oder API-Schlüssel verbinden",
"dialog.provider.openai.note": "Mit ChatGPT Pro/Plus oder API-Schlüssel verbinden",

View File

@@ -99,8 +99,6 @@ export const dict = {
"dialog.provider.group.other": "Other",
"dialog.provider.tag.recommended": "Recommended",
"dialog.provider.opencode.note": "Curated models including Claude, GPT, Gemini and more",
"dialog.provider.opencode.tagline": "Reliable optimized models",
"dialog.provider.opencodeGo.tagline": "Low cost subscription for everyone",
"dialog.provider.anthropic.note": "Direct access to Claude models, including Pro and Max",
"dialog.provider.copilot.note": "AI models for coding assistance via GitHub Copilot",
"dialog.provider.openai.note": "GPT models for fast, capable general AI tasks",
@@ -309,17 +307,12 @@ export const dict = {
"dialog.server.description": "Switch which OpenCode server this app connects to.",
"dialog.server.search.placeholder": "Search servers",
"dialog.server.empty": "No servers yet",
"dialog.server.add.title": "Add server",
"dialog.server.add.url": "Server address",
"dialog.server.add.title": "Add a server",
"dialog.server.add.url": "Server URL",
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "Could not connect to server",
"dialog.server.add.checking": "Checking...",
"dialog.server.add.button": "Add server",
"dialog.server.add.name": "Server name (optional)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Username (optional)",
"dialog.server.add.password": "Password (optional)",
"dialog.server.edit.title": "Edit server",
"dialog.server.default.title": "Default server",
"dialog.server.default.description":
"Connect to this server on app launch instead of starting a local server. Requires restart.",

View File

@@ -99,8 +99,6 @@ export const dict = {
"dialog.provider.group.other": "Otro",
"dialog.provider.tag.recommended": "Recomendado",
"dialog.provider.opencode.note": "Modelos seleccionados incluyendo Claude, GPT, Gemini y más",
"dialog.provider.opencode.tagline": "Modelos optimizados y fiables",
"dialog.provider.opencodeGo.tagline": "Suscripción económica para todos",
"dialog.provider.anthropic.note": "Acceso directo a modelos Claude, incluyendo Pro y Max",
"dialog.provider.copilot.note": "Modelos de IA para asistencia de codificación a través de GitHub Copilot",
"dialog.provider.openai.note": "Modelos GPT para tareas de IA generales rápidas y capaces",

View File

@@ -91,8 +91,6 @@ export const dict = {
"dialog.provider.group.other": "Autre",
"dialog.provider.tag.recommended": "Recommandé",
"dialog.provider.opencode.note": "Modèles sélectionnés incluant Claude, GPT, Gemini et plus",
"dialog.provider.opencode.tagline": "Modèles optimisés et fiables",
"dialog.provider.opencodeGo.tagline": "Abonnement abordable pour tous",
"dialog.provider.anthropic.note": "Connectez-vous avec Claude Pro/Max ou une clé API",
"dialog.provider.copilot.note": "Connectez-vous avec Copilot ou une clé API",
"dialog.provider.openai.note": "Connectez-vous avec ChatGPT Pro/Plus ou une clé API",

View File

@@ -91,8 +91,6 @@ export const dict = {
"dialog.provider.group.other": "その他",
"dialog.provider.tag.recommended": "推奨",
"dialog.provider.opencode.note": "Claude, GPT, Geminiなどを含む厳選されたモデル",
"dialog.provider.opencode.tagline": "信頼性の高い最適化モデル",
"dialog.provider.opencodeGo.tagline": "すべての人に低価格のサブスクリプション",
"dialog.provider.anthropic.note": "Claude Pro/MaxまたはAPIキーで接続",
"dialog.provider.copilot.note": "CopilotまたはAPIキーで接続",
"dialog.provider.openai.note": "ChatGPT Pro/PlusまたはAPIキーで接続",

View File

@@ -95,8 +95,6 @@ export const dict = {
"dialog.provider.group.other": "기타",
"dialog.provider.tag.recommended": "추천",
"dialog.provider.opencode.note": "Claude, GPT, Gemini 등을 포함한 엄선된 모델",
"dialog.provider.opencode.tagline": "신뢰할 수 있는 최적화 모델",
"dialog.provider.opencodeGo.tagline": "모두를 위한 저렴한 구독",
"dialog.provider.anthropic.note": "Claude Pro/Max 또는 API 키로 연결",
"dialog.provider.copilot.note": "Copilot 또는 API 키로 연결",
"dialog.provider.openai.note": "ChatGPT Pro/Plus 또는 API 키로 연결",

View File

@@ -102,8 +102,6 @@ export const dict = {
"dialog.provider.group.other": "Andre",
"dialog.provider.tag.recommended": "Anbefalt",
"dialog.provider.opencode.note": "Utvalgte modeller inkludert Claude, GPT, Gemini og mer",
"dialog.provider.opencode.tagline": "Pålitelige, optimaliserte modeller",
"dialog.provider.opencodeGo.tagline": "Rimelig abonnement for alle",
"dialog.provider.anthropic.note": "Direkte tilgang til Claude-modeller, inkludert Pro og Max",
"dialog.provider.copilot.note": "AI-modeller for kodeassistanse via GitHub Copilot",
"dialog.provider.openai.note": "GPT-modeller for raske, dyktige generelle AI-oppgaver",

View File

@@ -91,8 +91,6 @@ export const dict = {
"dialog.provider.group.other": "Inne",
"dialog.provider.tag.recommended": "Zalecane",
"dialog.provider.opencode.note": "Wyselekcjonowane modele, w tym Claude, GPT, Gemini i inne",
"dialog.provider.opencode.tagline": "Niezawodne, zoptymalizowane modele",
"dialog.provider.opencodeGo.tagline": "Tania subskrypcja dla każdego",
"dialog.provider.anthropic.note": "Bezpośredni dostęp do modeli Claude, w tym Pro i Max",
"dialog.provider.copilot.note": "Modele AI do pomocy w kodowaniu przez GitHub Copilot",
"dialog.provider.openai.note": "Modele GPT do szybkich i wszechstronnych zadań AI",

View File

@@ -99,8 +99,6 @@ export const dict = {
"dialog.provider.group.other": "Другие",
"dialog.provider.tag.recommended": "Рекомендуемые",
"dialog.provider.opencode.note": "Отобранные модели, включая Claude, GPT, Gemini и другие",
"dialog.provider.opencode.tagline": "Надежные оптимизированные модели",
"dialog.provider.opencodeGo.tagline": "Доступная подписка для всех",
"dialog.provider.anthropic.note": "Прямой доступ к моделям Claude, включая Pro и Max",
"dialog.provider.copilot.note": "ИИ-модели для помощи в кодировании через GitHub Copilot",
"dialog.provider.openai.note": "Модели GPT для быстрых и мощных задач общего ИИ",

View File

@@ -99,8 +99,6 @@ export const dict = {
"dialog.provider.group.other": "อื่น ๆ",
"dialog.provider.tag.recommended": "แนะนำ",
"dialog.provider.opencode.note": "โมเดลที่คัดสรร รวมถึง Claude, GPT, Gemini และอื่น ๆ",
"dialog.provider.opencode.tagline": "โมเดลที่เชื่อถือได้และปรับให้เหมาะสม",
"dialog.provider.opencodeGo.tagline": "การสมัครสมาชิกราคาประหยัดสำหรับทุกคน",
"dialog.provider.anthropic.note": "เข้าถึงโมเดล Claude โดยตรง รวมถึง Pro และ Max",
"dialog.provider.copilot.note": "โมเดล AI สำหรับการช่วยเหลือในการเขียนโค้ดผ่าน GitHub Copilot",
"dialog.provider.openai.note": "โมเดล GPT สำหรับงาน AI ทั่วไปที่รวดเร็วและมีความสามารถ",

View File

@@ -126,8 +126,6 @@ export const dict = {
"dialog.provider.group.other": "其他",
"dialog.provider.tag.recommended": "推荐",
"dialog.provider.opencode.note": "使用 OpenCode Zen 或 API 密钥连接",
"dialog.provider.opencode.tagline": "可靠的优化模型",
"dialog.provider.opencodeGo.tagline": "适合所有人的低成本订阅",
"dialog.provider.anthropic.note": "使用 Claude Pro/Max 或 API 密钥连接",
"dialog.provider.copilot.note": "使用 Copilot 或 API 密钥连接",
"dialog.provider.openai.note": "使用 ChatGPT Pro/Plus 或 API 密钥连接",

View File

@@ -103,8 +103,6 @@ export const dict = {
"dialog.provider.group.other": "其他",
"dialog.provider.tag.recommended": "推薦",
"dialog.provider.opencode.note": "精選模型,包含 Claude、GPT、Gemini 等等",
"dialog.provider.opencode.tagline": "可靠的優化模型",
"dialog.provider.opencodeGo.tagline": "適合所有人的低成本訂閱",
"dialog.provider.anthropic.note": "使用 Claude Pro/Max 或 API 金鑰連線",
"dialog.provider.openai.note": "使用 ChatGPT Pro/Plus 或 API 金鑰連線",
"dialog.provider.copilot.note": "使用 Copilot 或 API 金鑰連線",

View File

@@ -1,11 +1,12 @@
import { createEffect, createMemo, Show, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
import { SDKProvider } from "@/context/sdk"
import { SDKProvider, useSDK } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local"
import { DataProvider } from "@opencode-ai/ui/context"
import type { QuestionAnswer } from "@opencode-ai/sdk/v2"
import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
@@ -14,11 +15,19 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const params = useParams()
const navigate = useNavigate()
const sync = useSync()
const sdk = useSDK()
return (
<DataProvider
data={sync.data}
directory={props.directory}
onPermissionRespond={(input: {
sessionID: string
permissionID: string
response: "once" | "always" | "reject"
}) => sdk.client.permission.respond(input)}
onQuestionReply={(input: { requestID: string; answers: QuestionAnswer[] }) => sdk.client.question.reply(input)}
onQuestionReject={(input: { requestID: string }) => sdk.client.question.reject(input)}
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
>

View File

@@ -61,7 +61,6 @@ import {
displayName,
errorMessage,
getDraggableId,
latestRootSession,
sortedRootSessions,
syncWorkspaceOrder,
workspaceKey,
@@ -1094,51 +1093,14 @@ export default function Layout(props: ParentProps) {
return meta?.worktree ?? directory
}
async function navigateToProject(directory: string | undefined) {
function navigateToProject(directory: string | undefined) {
if (!directory) return
const root = projectRoot(directory)
server.projects.touch(root)
const project = layout.projects.list().find((item) => item.worktree === root)
const dirs = Array.from(new Set([root, ...(store.workspaceOrder[root] ?? []), ...(project?.sandboxes ?? [])]))
const openSession = async (target: { directory: string; id: string }) => {
const resolved = await globalSDK.client.session
.get({ sessionID: target.id })
.then((x) => x.data)
.catch(() => undefined)
const next = resolved?.directory ? resolved : target
setStore("lastProjectSession", root, { directory: next.directory, id: next.id, at: Date.now() })
navigateWithSidebarReset(`/${base64Encode(next.directory)}/session/${next.id}`)
}
const projectSession = store.lastProjectSession[root]
if (projectSession?.id) {
await openSession(projectSession)
return
}
const latest = latestRootSession(
dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]),
Date.now(),
)
if (latest) {
await openSession(latest)
return
}
const fetched = latestRootSession(
await Promise.all(
dirs.map(async (item) => ({
path: { directory: item },
session: await globalSDK.client.session
.list({ directory: item })
.then((x) => x.data ?? [])
.catch(() => []),
})),
),
Date.now(),
)
if (fetched) {
await openSession(fetched)
navigateWithSidebarReset(`/${base64Encode(projectSession.directory)}/session/${projectSession.id}`)
return
}

View File

@@ -1,25 +1,6 @@
import { describe, expect, test } from "bun:test"
import { type Session } from "@opencode-ai/sdk/v2/client"
import { collectOpenProjectDeepLinks, drainPendingDeepLinks, parseDeepLink } from "./deep-links"
import {
displayName,
errorMessage,
getDraggableId,
latestRootSession,
syncWorkspaceOrder,
workspaceKey,
} from "./helpers"
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
({
title: "",
version: "v2",
parentID: undefined,
messageCount: 0,
permissions: { session: {}, share: {} },
time: { created: 0, updated: 0, archived: undefined },
...input,
}) as Session
import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers"
describe("layout deep links", () => {
test("parses open-project deep links", () => {
@@ -92,61 +73,6 @@ describe("layout workspace helpers", () => {
expect(result).toEqual(["/root", "/c", "/b"])
})
test("finds the latest root session across workspaces", () => {
const result = latestRootSession(
[
{
path: { directory: "/root" },
session: [session({ id: "root", directory: "/root", time: { created: 1, updated: 1, archived: undefined } })],
},
{
path: { directory: "/workspace" },
session: [
session({
id: "workspace",
directory: "/workspace",
time: { created: 2, updated: 2, archived: undefined },
}),
],
},
],
120_000,
)
expect(result?.id).toBe("workspace")
})
test("ignores archived and child sessions when finding latest root session", () => {
const result = latestRootSession(
[
{
path: { directory: "/workspace" },
session: [
session({
id: "archived",
directory: "/workspace",
time: { created: 10, updated: 10, archived: 10 },
}),
session({
id: "child",
directory: "/workspace",
parentID: "parent",
time: { created: 20, updated: 20, archived: undefined },
}),
session({
id: "root",
directory: "/workspace",
time: { created: 30, updated: 30, archived: undefined },
}),
],
},
],
120_000,
)
expect(result?.id).toBe("root")
})
test("extracts draggable id safely", () => {
expect(getDraggableId({ draggable: { id: "x" } })).toBe("x")
expect(getDraggableId({ draggable: { id: 42 } })).toBeUndefined()

View File

@@ -28,11 +28,6 @@ export const isRootVisibleSession = (session: Session, directory: string) =>
export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) =>
store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now))
export const latestRootSession = (stores: { session: Session[]; path: { directory: string } }[], now: number) =>
stores
.flatMap((store) => store.session.filter((session) => isRootVisibleSession(session, store.path.directory)))
.sort(sortSessions(now))[0]
export const childMapByParent = (sessions: Session[]) => {
const map = new Map<string, string[]>()
for (const session of sessions) {

View File

@@ -1,5 +1,4 @@
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createEffect, createMemo, createSignal, For, Show, type Accessor, type JSX } from "solid-js"
import { base64Encode } from "@opencode-ai/util/encode"
import { Button } from "@opencode-ai/ui/button"
import { ContextMenu } from "@opencode-ai/ui/context-menu"
@@ -8,7 +7,7 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { createSortable } from "@thisbeyond/solid-dnd"
import { useLayout, type LocalProject } from "@/context/layout"
import { type LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
@@ -61,7 +60,6 @@ const ProjectTile = (props: {
selected: Accessor<boolean>
active: Accessor<boolean>
overlay: Accessor<boolean>
suppressHover: Accessor<boolean>
dirs: Accessor<string[]>
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
onProjectMouseLeave: (worktree: string) => void
@@ -73,11 +71,9 @@ const ProjectTile = (props: {
closeProject: (directory: string) => void
setMenu: (value: boolean) => void
setOpen: (value: boolean) => void
setSuppressHover: (value: boolean) => void
language: ReturnType<typeof useLanguage>
}): JSX.Element => {
const notification = useNotification()
const layout = useLayout()
const unseenCount = createMemo(() =>
props.dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
)
@@ -111,28 +107,17 @@ const ProjectTile = (props: {
}}
onMouseEnter={(event: MouseEvent) => {
if (!props.overlay()) return
if (props.suppressHover()) return
props.onProjectMouseEnter(props.project.worktree, event)
}}
onMouseLeave={() => {
if (props.suppressHover()) props.setSuppressHover(false)
if (!props.overlay()) return
props.onProjectMouseLeave(props.project.worktree)
}}
onFocus={() => {
if (!props.overlay()) return
if (props.suppressHover()) return
props.onProjectFocus(props.project.worktree)
}}
onClick={() => {
if (props.selected()) {
props.setSuppressHover(true)
layout.sidebar.toggle()
return
}
props.setSuppressHover(false)
props.navigateToProject(props.project.worktree)
}}
onClick={() => props.navigateToProject(props.project.worktree)}
onBlur={() => props.setOpen(false)}
>
<ProjectIcon project={props.project} notify />
@@ -293,19 +278,16 @@ export const SortableProject = (props: {
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
const [state, setState] = createStore({
open: false,
menu: false,
suppressHover: false,
})
const [open, setOpen] = createSignal(false)
const [menu, setMenu] = createSignal(false)
const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
const active = createMemo(() =>
projectTileActive({
menu: state.menu,
menu: menu(),
preview: preview(),
open: state.open,
open: open(),
overlay: overlay(),
hoverProject: props.ctx.hoverProject(),
worktree: props.project.worktree,
@@ -314,14 +296,8 @@ export const SortableProject = (props: {
createEffect(() => {
if (preview()) return
if (!state.open) return
setState("open", false)
})
createEffect(() => {
if (!selected()) return
if (!state.open) return
setState("open", false)
if (!open()) return
setOpen(false)
})
const label = (directory: string) => {
@@ -352,7 +328,6 @@ export const SortableProject = (props: {
selected={selected}
active={active}
overlay={overlay}
suppressHover={() => state.suppressHover}
dirs={dirs}
onProjectMouseEnter={props.ctx.onProjectMouseEnter}
onProjectMouseLeave={props.ctx.onProjectMouseLeave}
@@ -362,9 +337,8 @@ export const SortableProject = (props: {
toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces}
workspacesEnabled={props.ctx.workspacesEnabled}
closeProject={props.ctx.closeProject}
setMenu={(value) => setState("menu", value)}
setOpen={(value) => setState("open", value)}
setSuppressHover={(value) => setState("suppressHover", value)}
setMenu={setMenu}
setOpen={setOpen}
language={language}
/>
)
@@ -372,18 +346,17 @@ export const SortableProject = (props: {
return (
// @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Show when={preview() && !selected()} fallback={tile()}>
<Show when={preview()} fallback={tile()}>
<HoverCard
open={!state.suppressHover && state.open && !state.menu}
open={open() && !menu()}
openDelay={0}
closeDelay={0}
placement="right-start"
gutter={6}
trigger={tile()}
onOpenChange={(value) => {
if (state.menu) return
if (value && state.suppressHover) return
setState("open", value)
if (menu()) return
setOpen(value)
if (value) props.ctx.setHoverSession(undefined)
}}
>
@@ -398,7 +371,7 @@ export const SortableProject = (props: {
projectChildren={projectChildren}
workspaceSessions={workspaceSessions}
workspaceChildren={workspaceChildren}
setOpen={(value) => setState("open", value)}
setOpen={setOpen}
ctx={props.ctx}
language={language}
/>

View File

@@ -379,11 +379,32 @@ export default function Page() {
})
}
const isEditableTarget = (target: EventTarget | null | undefined) => {
if (!(target instanceof HTMLElement)) return false
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
}
const deepActiveElement = () => {
let current: Element | null = document.activeElement
while (current instanceof HTMLElement && current.shadowRoot?.activeElement) {
current = current.shadowRoot.activeElement
}
return current instanceof HTMLElement ? current : undefined
}
const handleKeyDown = (event: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement | undefined
const path = event.composedPath()
const target = path.find((item): item is HTMLElement => item instanceof HTMLElement)
const activeElement = deepActiveElement()
const protectedTarget = path.some(
(item) => item instanceof HTMLElement && item.closest("[data-prevent-autofocus]") !== null,
)
if (protectedTarget || isEditableTarget(target)) return
if (activeElement) {
const isProtected = activeElement.closest("[data-prevent-autofocus]")
const isInput = /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(activeElement.tagName) || activeElement.isContentEditable
const isInput = isEditableTarget(activeElement)
if (isProtected || isInput) return
}
if (dialog.active) return
@@ -416,7 +437,7 @@ export default function Page() {
)
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
const reviewTab = createMemo(() => isDesktop())
const reviewTab = createMemo(() => isDesktop() && !layout.fileTree.opened())
const fileTreeTab = () => layout.fileTree.tab()
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
@@ -700,12 +721,33 @@ export default function Page() {
const active = tabs().active()
const tab = active === "review" || (!active && hasReview()) ? "changes" : "all"
layout.fileTree.setTab(tab)
return
}
if (fileTreeTab() !== "changes") return
tabs().setActive("review")
},
{ defer: true },
),
)
createEffect(() => {
if (!isDesktop()) return
if (!layout.fileTree.opened()) return
if (fileTreeTab() !== "all") return
const active = tabs().active()
if (active && active !== "review") return
const first = openedTabs()[0]
if (first) {
tabs().setActive(first)
return
}
if (contextOpen()) tabs().setActive("context")
})
createEffect(() => {
const id = params.id
if (!id) return

View File

@@ -1,83 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
const session = (input: { id: string; parentID?: string }) =>
({
id: input.id,
parentID: input.parentID,
}) as Session
const permission = (id: string, sessionID: string) =>
({
id,
sessionID,
}) as PermissionRequest
const question = (id: string, sessionID: string) =>
({
id,
sessionID,
questions: [],
}) as QuestionRequest
describe("sessionPermissionRequest", () => {
test("prefers the current session permission", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const permissions = {
root: [permission("perm-root", "root")],
child: [permission("perm-child", "child")],
}
expect(sessionPermissionRequest(sessions, permissions, "root")?.id).toBe("perm-root")
})
test("returns a nested child permission", () => {
const sessions = [
session({ id: "root" }),
session({ id: "child", parentID: "root" }),
session({ id: "grand", parentID: "child" }),
session({ id: "other" }),
]
const permissions = {
grand: [permission("perm-grand", "grand")],
other: [permission("perm-other", "other")],
}
expect(sessionPermissionRequest(sessions, permissions, "root")?.id).toBe("perm-grand")
})
test("returns undefined without a matching tree permission", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const permissions = {
other: [permission("perm-other", "other")],
}
expect(sessionPermissionRequest(sessions, permissions, "root")).toBeUndefined()
})
})
describe("sessionQuestionRequest", () => {
test("prefers the current session question", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const questions = {
root: [question("q-root", "root")],
child: [question("q-child", "child")],
}
expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-root")
})
test("returns a nested child question", () => {
const sessions = [
session({ id: "root" }),
session({ id: "child", parentID: "root" }),
session({ id: "grand", parentID: "child" }),
]
const questions = {
grand: [question("q-grand", "grand")],
}
expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-grand")
})
})

View File

@@ -7,20 +7,14 @@ import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
export function createSessionComposerBlocked() {
const params = useParams()
const sync = useSync()
const permissionRequest = createMemo(() =>
sessionPermissionRequest(sync.data.session, sync.data.permission, params.id),
)
const questionRequest = createMemo(() => sessionQuestionRequest(sync.data.session, sync.data.question, params.id))
return createMemo(() => {
const id = params.id
if (!id) return false
return !!permissionRequest() || !!questionRequest()
return !!sync.data.permission[id]?.[0] || !!sync.data.question[id]?.[0]
})
}
@@ -32,18 +26,18 @@ export function createSessionComposerState() {
const language = useLanguage()
const questionRequest = createMemo((): QuestionRequest | undefined => {
return sessionQuestionRequest(sync.data.session, sync.data.question, params.id)
const id = params.id
if (!id) return
return sync.data.question[id]?.[0]
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
return sessionPermissionRequest(sync.data.session, sync.data.permission, params.id)
const id = params.id
if (!id) return
return sync.data.permission[id]?.[0]
})
const blocked = createMemo(() => {
const id = params.id
if (!id) return false
return !!permissionRequest() || !!questionRequest()
})
const blocked = createSessionComposerBlocked()
const todos = createMemo((): Todo[] => {
const id = params.id

View File

@@ -1,45 +0,0 @@
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
function sessionTreeRequest<T>(session: Session[], request: Record<string, T[] | undefined>, sessionID?: string) {
if (!sessionID) return
const map = session.reduce((acc, item) => {
if (!item.parentID) return acc
const list = acc.get(item.parentID)
if (list) list.push(item.id)
if (!list) acc.set(item.parentID, [item.id])
return acc
}, new Map<string, string[]>())
const seen = new Set([sessionID])
const ids = [sessionID]
for (const id of ids) {
const list = map.get(id)
if (!list) continue
for (const child of list) {
if (seen.has(child)) continue
seen.add(child)
ids.push(child)
}
}
const id = ids.find((id) => !!request[id]?.[0])
if (!id) return
return request[id]?.[0]
}
export function sessionPermissionRequest(
session: Session[],
request: Record<string, PermissionRequest[] | undefined>,
sessionID?: string,
) {
return sessionTreeRequest(session, request, sessionID)
}
export function sessionQuestionRequest(
session: Session[],
request: Record<string, QuestionRequest[] | undefined>,
sessionID?: string,
) {
return sessionTreeRequest(session, request, sessionID)
}

View File

@@ -1,12 +1,12 @@
import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createEffect, createMemo, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { useParams } from "@solidjs/router"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
import { useFileComponent } from "@opencode-ai/ui/context/file"
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { createLineCommentController } from "@opencode-ai/ui/line-comment-annotations"
import { sampledChecksum } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast"
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
import { Mark } from "@opencode-ai/ui/logo"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
@@ -17,13 +17,6 @@ import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
import { getSessionHandoff } from "@/pages/session/handoff"
const formatCommentLabel = (range: SelectedLineRange) => {
const start = Math.min(range.start, range.end)
const end = Math.max(range.start, range.end)
if (start === end) return `line ${start}`
return `lines ${start}-${end}`
}
export function FileTabContent(props: { tab: string }) {
const params = useParams()
const layout = useLayout()
@@ -31,7 +24,7 @@ export function FileTabContent(props: { tab: string }) {
const comments = useComments()
const language = useLanguage()
const prompt = usePrompt()
const codeComponent = useCodeComponent()
const fileComponent = useFileComponent()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
@@ -50,66 +43,24 @@ export function FileTabContent(props: { tab: string }) {
})
const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => sampledChecksum(contents()))
const isImage = createMemo(() => {
const c = state()?.content
return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
})
const isSvg = createMemo(() => {
const c = state()?.content
return c?.mimeType === "image/svg+xml"
})
const isBinary = createMemo(() => state()?.content?.type === "binary")
const svgContent = createMemo(() => {
if (!isSvg()) return
const c = state()?.content
if (!c) return
if (c.encoding !== "base64") return c.content
return decode64(c.content)
})
const svgDecodeFailed = createMemo(() => {
if (!isSvg()) return false
const c = state()?.content
if (!c) return false
if (c.encoding !== "base64") return false
return svgContent() === undefined
})
const svgToast = { shown: false }
createEffect(() => {
if (!svgDecodeFailed()) return
if (svgToast.shown) return
svgToast.shown = true
showToast({
variant: "error",
title: language.t("toast.file.loadFailed.title"),
})
path()
svgToast.shown = false
})
const svgPreviewUrl = createMemo(() => {
if (!isSvg()) return
const c = state()?.content
if (!c) return
if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
})
const imageDataUrl = createMemo(() => {
if (!isImage()) return
const c = state()?.content
return `data:${c?.mimeType};base64,${c?.content}`
})
const selectedLines = createMemo(() => {
const selectedLines = createMemo<SelectedLineRange | null>(() => {
const p = path()
if (!p) return null
if (file.ready()) return file.selectedLines(p) ?? null
return getSessionHandoff(sessionKey())?.files[p] ?? null
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
})
const selectionPreview = (source: string, selection: FileSelection) => {
const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
const end = Math.max(selection.startLine, selection.endLine)
const lines = source.split("\n").slice(start - 1, end)
if (lines.length === 0) return undefined
return lines.slice(0, 2).join("\n")
return previewSelectedLines(source, {
start: selection.startLine,
end: selection.endLine,
})
}
const addCommentToContext = (input: {
@@ -145,129 +96,73 @@ export function FileTabContent(props: { tab: string }) {
})
}
let wrap: HTMLDivElement | undefined
const fileComments = createMemo(() => {
const p = path()
if (!p) return []
return comments.list(p)
})
const commentLayout = createMemo(() => {
return fileComments()
.map((comment) => `${comment.id}:${comment.selection.start}:${comment.selection.end}`)
.join("|")
})
const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
const [note, setNote] = createStore({
openedComment: null as string | null,
commenting: null as SelectedLineRange | null,
draft: "",
positions: {} as Record<string, number>,
draftTop: undefined as number | undefined,
selected: null as SelectedLineRange | null,
})
const setCommenting = (range: SelectedLineRange | null) => {
setNote("commenting", range)
scheduleComments()
if (!range) return
setNote("draft", "")
const syncSelected = (range: SelectedLineRange | null) => {
const p = path()
if (!p) return
file.setSelectedLines(p, range ? cloneSelectedLineRange(range) : null)
}
const getRoot = () => {
const el = wrap
if (!el) return
const activeSelection = () => note.selected ?? selectedLines()
const host = el.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return
const commentsUi = createLineCommentController({
comments: fileComments,
label: language.t("ui.lineComment.submit"),
draftKey: () => path() ?? props.tab,
state: {
opened: () => note.openedComment,
setOpened: (id) => setNote("openedComment", id),
selected: () => note.selected,
setSelected: (range) => setNote("selected", range),
commenting: () => note.commenting,
setCommenting: (range) => setNote("commenting", range),
syncSelected,
hoverSelected: syncSelected,
},
getHoverSelectedRange: activeSelection,
cancelDraftOnCommentToggle: true,
clearSelectionOnSelectionEndNull: true,
onSubmit: ({ comment, selection }) => {
const p = path()
if (!p) return
addCommentToContext({ file: p, selection, comment, origin: "file" })
},
onDraftPopoverFocusOut: (e: FocusEvent) => {
const current = e.currentTarget as HTMLDivElement
const target = e.relatedTarget
if (target instanceof Node && current.contains(target)) return
const root = host.shadowRoot
if (!root) return
return root
}
const findMarker = (root: ShadowRoot, range: SelectedLineRange) => {
const line = Math.max(range.start, range.end)
const node = root.querySelector(`[data-line="${line}"]`)
if (!(node instanceof HTMLElement)) return
return node
}
const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => {
const wrapperRect = wrapper.getBoundingClientRect()
const rect = marker.getBoundingClientRect()
return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
}
const updateComments = () => {
const el = wrap
const root = getRoot()
if (!el || !root) {
setNote("positions", {})
setNote("draftTop", undefined)
return
}
const estimateTop = (range: SelectedLineRange) => {
const line = Math.max(range.start, range.end)
const height = 24
const offset = 2
return Math.max(0, (line - 1) * height + offset)
}
const large = contents().length > 500_000
const next: Record<string, number> = {}
for (const comment of fileComments()) {
const marker = findMarker(root, comment.selection)
if (marker) next[comment.id] = markerTop(el, marker)
else if (large) next[comment.id] = estimateTop(comment.selection)
}
const removed = Object.keys(note.positions).filter((id) => next[id] === undefined)
const changed = Object.entries(next).filter(([id, top]) => note.positions[id] !== top)
if (removed.length > 0 || changed.length > 0) {
setNote(
"positions",
produce((draft) => {
for (const id of removed) {
delete draft[id]
}
for (const [id, top] of changed) {
draft[id] = top
}
}),
)
}
const range = note.commenting
if (!range) {
setNote("draftTop", undefined)
return
}
const marker = findMarker(root, range)
if (marker) {
setNote("draftTop", markerTop(el, marker))
return
}
setNote("draftTop", large ? estimateTop(range) : undefined)
}
const scheduleComments = () => {
requestAnimationFrame(updateComments)
}
createEffect(() => {
commentLayout()
scheduleComments()
setTimeout(() => {
if (!document.activeElement || !current.contains(document.activeElement)) {
setNote("commenting", null)
}
}, 0)
},
})
createEffect(
on(
path,
() => {
commentsUi.note.reset()
},
{ defer: true },
),
)
createEffect(() => {
const focus = comments.focus()
const p = path()
@@ -278,9 +173,7 @@ export function FileTabContent(props: { tab: string }) {
const target = fileComments().find((comment) => comment.id === focus.id)
if (!target) return
setNote("openedComment", target.id)
setCommenting(null)
file.setSelectedLines(p, target.selection)
commentsUi.note.openComment(target.id, target.selection, { cancelDraft: true })
requestAnimationFrame(() => comments.clearFocus())
})
@@ -419,99 +312,111 @@ export function FileTabContent(props: { tab: string }) {
cancelAnimationFrame(scrollFrame)
})
const renderCode = (source: string, wrapperClass: string) => (
<div
ref={(el) => {
wrap = el
scheduleComments()
}}
class={`relative overflow-hidden ${wrapperClass}`}
>
const renderText = (source: string, wrapperClass: string, key = cacheKey()) => (
<div class={`relative overflow-hidden ${wrapperClass}`}>
<Dynamic
component={codeComponent}
component={fileComponent}
mode="text"
file={{
name: path() ?? "",
contents: source,
cacheKey: key,
}}
enableLineSelection
enableHoverUtility
selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
requestAnimationFrame(restoreScroll)
}}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
renderHoverUtility={commentsUi.renderHoverUtility}
onLineSelected={(range: SelectedLineRange | null) => {
commentsUi.onLineSelected(range)
}}
onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd}
onLineSelectionEnd={(range: SelectedLineRange | null) => {
commentsUi.onLineSelectionEnd(range)
}}
overflow="scroll"
class="select-text"
media={{ mode: "off" }}
/>
</div>
)
const renderFile = (source: string, wrapperClass: string) => (
<div class={`relative overflow-hidden ${wrapperClass}`}>
<Dynamic
component={fileComponent}
mode="text"
file={{
name: path() ?? "",
contents: source,
cacheKey: cacheKey(),
}}
enableLineSelection
selectedLines={selectedLines()}
enableHoverUtility
selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
requestAnimationFrame(restoreScroll)
requestAnimationFrame(scheduleComments)
}}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
renderHoverUtility={commentsUi.renderHoverUtility}
onLineSelected={(range: SelectedLineRange | null) => {
const p = path()
if (!p) return
file.setSelectedLines(p, range)
if (!range) setCommenting(null)
commentsUi.onLineSelected(range)
}}
onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd}
onLineSelectionEnd={(range: SelectedLineRange | null) => {
if (!range) {
setCommenting(null)
return
}
setNote("openedComment", null)
setCommenting(range)
commentsUi.onLineSelectionEnd(range)
}}
overflow="scroll"
class="select-text"
media={{
mode: "auto",
path: path(),
current: state()?.content,
onLoad: () => requestAnimationFrame(restoreScroll),
onError: (args: { kind: "image" | "audio" | "svg" }) => {
if (args.kind !== "svg") return
if (svgToast.shown) return
svgToast.shown = true
showToast({
variant: "error",
title: language.t("toast.file.loadFailed.title"),
})
},
renderImage: (args: { src: string; onLoad: () => void }) => (
<div class="px-6 py-4 pb-40">
<img src={args.src} alt={path()} class="max-w-full" onLoad={args.onLoad} />
</div>
),
renderSvg: (args: { src?: string; source: string; onLoad: () => void }) => (
<div class="flex flex-col gap-4 px-6 py-4">
{renderText(args.source, "", sampledChecksum(args.source))}
<Show when={args.src}>
{(src) => (
<div class="flex justify-center pb-40">
<img src={src()} alt={path()} class="max-w-full max-h-96" onLoad={args.onLoad} />
</div>
)}
</Show>
</div>
),
renderBinaryPlaceholder: () => (
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="flex flex-col gap-2 max-w-md">
<div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
<div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
</div>
</div>
),
}}
/>
<For each={fileComments()}>
{(comment) => (
<LineCommentView
id={comment.id}
top={note.positions[comment.id]}
open={note.openedComment === comment.id}
comment={comment.comment}
selection={formatCommentLabel(comment.selection)}
onMouseEnter={() => {
const p = path()
if (!p) return
file.setSelectedLines(p, comment.selection)
}}
onClick={() => {
const p = path()
if (!p) return
setCommenting(null)
setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
file.setSelectedLines(p, comment.selection)
}}
/>
)}
</For>
<Show when={note.commenting}>
{(range) => (
<Show when={note.draftTop !== undefined}>
<LineCommentEditor
top={note.draftTop}
value={note.draft}
selection={formatCommentLabel(range())}
onInput={(value) => setNote("draft", value)}
onCancel={cancelCommenting}
onSubmit={(value) => {
const p = path()
if (!p) return
addCommentToContext({ file: p, selection: range(), comment: value, origin: "file" })
setCommenting(null)
}}
onPopoverFocusOut={(e: FocusEvent) => {
const current = e.currentTarget as HTMLDivElement
const target = e.relatedTarget
if (target instanceof Node && current.contains(target)) return
setTimeout(() => {
if (!document.activeElement || !current.contains(document.activeElement)) {
cancelCommenting()
}
}, 0)
}}
/>
</Show>
)}
</Show>
</div>
)
@@ -526,36 +431,7 @@ export function FileTabContent(props: { tab: string }) {
onScroll={handleScroll as any}
>
<Switch>
<Match when={state()?.loaded && isImage()}>
<div class="px-6 py-4 pb-40">
<img
src={imageDataUrl()}
alt={path()}
class="max-w-full"
onLoad={() => requestAnimationFrame(restoreScroll)}
/>
</div>
</Match>
<Match when={state()?.loaded && isSvg()}>
<div class="flex flex-col gap-4 px-6 py-4">
{renderCode(svgContent() ?? "", "")}
<Show when={svgPreviewUrl()}>
<div class="flex justify-center pb-40">
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
</div>
</Show>
</div>
</Match>
<Match when={state()?.loaded && isBinary()}>
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="flex flex-col gap-2 max-w-md">
<div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
<div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
</div>
</div>
</Match>
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
<Match when={state()?.loaded}>{renderFile(contents(), "pb-40")}</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
</Match>

View File

@@ -70,10 +70,11 @@ export function StickyAddButton(props: { children: JSX.Element }) {
export function SessionReviewTab(props: SessionReviewTabProps) {
let scroll: HTMLDivElement | undefined
let frame: number | undefined
let pending: { x: number; y: number } | undefined
let restoreFrame: number | undefined
let userInteracted = false
const sdk = useSDK()
const layout = useLayout()
const readFile = async (path: string) => {
return sdk.client.file
@@ -85,48 +86,81 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
})
}
const restoreScroll = () => {
const handleInteraction = () => {
userInteracted = true
}
const doRestore = () => {
restoreFrame = undefined
const el = scroll
if (!el) return
if (!el || !layout.ready() || userInteracted) return
if (el.clientHeight === 0 || el.clientWidth === 0) return
const s = props.view().scroll("review")
if (!s) return
if (!s || (s.x === 0 && s.y === 0)) return
if (el.scrollTop !== s.y) el.scrollTop = s.y
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
const maxY = Math.max(0, el.scrollHeight - el.clientHeight)
const maxX = Math.max(0, el.scrollWidth - el.clientWidth)
const targetY = Math.min(s.y, maxY)
const targetX = Math.min(s.x, maxX)
if (el.scrollTop !== targetY) el.scrollTop = targetY
if (el.scrollLeft !== targetX) el.scrollLeft = targetX
}
const queueRestore = () => {
if (userInteracted || restoreFrame !== undefined) return
restoreFrame = requestAnimationFrame(doRestore)
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
pending = {
x: event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop,
}
if (frame !== undefined) return
if (!layout.ready() || !userInteracted) return
frame = requestAnimationFrame(() => {
frame = undefined
const el = event.currentTarget
if (el.clientHeight === 0 || el.clientWidth === 0) return
const next = pending
pending = undefined
if (!next) return
props.view().setScroll("review", next)
props.view().setScroll("review", {
x: el.scrollLeft,
y: el.scrollTop,
})
}
createEffect(
on(
() => props.diffs().length,
() => {
requestAnimationFrame(restoreScroll)
() => queueRestore(),
{ defer: true },
),
)
createEffect(
on(
() => props.diffStyle,
() => queueRestore(),
{ defer: true },
),
)
createEffect(
on(
() => layout.ready(),
(ready) => {
if (!ready) return
queueRestore()
},
{ defer: true },
),
)
onCleanup(() => {
if (frame === undefined) return
cancelAnimationFrame(frame)
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
if (scroll) {
scroll.removeEventListener("wheel", handleInteraction)
scroll.removeEventListener("pointerdown", handleInteraction)
scroll.removeEventListener("touchstart", handleInteraction)
scroll.removeEventListener("keydown", handleInteraction)
}
})
return (
@@ -135,11 +169,15 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
empty={props.empty}
scrollRef={(el) => {
scroll = el
el.addEventListener("wheel", handleInteraction, { passive: true, capture: true })
el.addEventListener("pointerdown", handleInteraction, { passive: true, capture: true })
el.addEventListener("touchstart", handleInteraction, { passive: true, capture: true })
el.addEventListener("keydown", handleInteraction, { passive: true, capture: true })
props.onScrollRef?.(el)
restoreScroll()
queueRestore()
}}
onScroll={handleScroll}
onDiffRendered={() => requestAnimationFrame(restoreScroll)}
onDiffRendered={queueRestore}
open={props.view().review.open()}
onOpenChange={props.view().review.setOpen}
classes={{

View File

@@ -47,7 +47,7 @@ export function SessionSidePanel(props: {
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const open = createMemo(() => isDesktop() && (view().reviewPanel.opened() || layout.fileTree.opened()))
const reviewTab = createMemo(() => isDesktop())
const reviewTab = createMemo(() => isDesktop() && !layout.fileTree.opened())
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
@@ -202,124 +202,133 @@ export function SessionSidePanel(props: {
>
<Show when={reviewOpen()}>
<div class="flex-1 min-w-0 h-full">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={activeTab()} onChange={openTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<Show when={reviewTab()}>
<Tabs.Trigger value="review" classes={{ button: "!pl-6" }}>
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={hasReview()}>
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
{reviewCount()}
<Show
when={layout.fileTree.opened() && fileTreeTab() === "changes"}
fallback={
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={activeTab()} onChange={openTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<Show when={reviewTab()}>
<Tabs.Trigger value="review" classes={{ button: "!pl-6" }}>
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={hasReview()}>
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
{reviewCount()}
</div>
</Show>
</div>
</Show>
</div>
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<Tooltip value={language.t("common.closeTab")} placement="bottom">
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<Tooltip value={language.t("common.closeTab")} placement="bottom">
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={openedTabs()}>
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
</SortableProvider>
<StickyAddButton>
<TooltipKeybind
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
>
<IconButton
icon="close-small"
icon="plus-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
iconSize="large"
onClick={() =>
dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
}
aria-label={language.t("command.file.open")}
/>
</Tooltip>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={openedTabs()}>
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
</SortableProvider>
<StickyAddButton>
<TooltipKeybind
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={() => dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)}
aria-label={language.t("command.file.open")}
/>
</TooltipKeybind>
</StickyAddButton>
</Tabs.List>
</div>
<Show when={reviewTab()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content>
</Show>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "empty"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
</div>
</TooltipKeybind>
</StickyAddButton>
</Tabs.List>
</div>
</Show>
</Tabs.Content>
<Show when={contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "context"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab />
</div>
<Show when={reviewTab()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content>
</Show>
</Tabs.Content>
</Show>
<Show when={activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />}
</Show>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable} keyed>
{(tab) => {
const path = createMemo(() => file.pathFromTab(tab))
return (
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "empty"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
</div>
</div>
</Show>
</Tabs.Content>
<Show when={contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "context"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab />
</div>
</Show>
</Tabs.Content>
</Show>
<Show when={activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />}
</Show>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable} keyed>
{(tab) => {
const path = createMemo(() => file.pathFromTab(tab))
return (
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
}
>
{props.reviewPanel()}
</Show>
</div>
</Show>

View File

@@ -1,13 +1,13 @@
{
"name": "@opencode-ai/console-app",
"version": "1.2.15",
"version": "1.2.14",
"type": "module",
"license": "MIT",
"scripts": {
"typecheck": "tsgo --noEmit",
"dev": "vite dev --host 0.0.0.0",
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51RtuLNE7fOCwHSD4mewwzFejyytjdGoSDK7CAvhbffwaZnPbNb2rwJICw6LTOXCmWO320fSNXvb5NzI08RZVkAxd00syfqrW7t bun sst shell --stage=dev bun dev",
"build": "bun ./script/generate-sitemap.ts && vite build && bun ../../opencode/script/schema.ts ./.output/public/config.json ./.output/public/tui.json",
"build": "bun ./script/generate-sitemap.ts && vite build && bun ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vite start"
},
"dependencies": {

View File

@@ -17,12 +17,6 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { formError } from "~/lib/form-error"
import { Resource } from "@opencode-ai/console-resource"
const getEnabled = query(async () => {
"use server"
return Resource.App.stage !== "production"
}, "black.subscribe.enabled")
const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record<PlanID, (typeof plans)[number]>
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!)
@@ -275,7 +269,6 @@ export default function BlackSubscribe() {
const params = useParams()
const i18n = useI18n()
const language = useLanguage()
const enabled = createAsync(() => getEnabled())
const planData = plansMap[(params.plan as PlanID) ?? "20"] ?? plansMap["20"]
const plan = planData.id
@@ -366,7 +359,7 @@ export default function BlackSubscribe() {
}
return (
<Show when={enabled()}>
<>
<Title>{i18n.t("black.subscribe.title")}</Title>
<section data-slot="subscribe-form">
<div data-slot="form-card">
@@ -479,6 +472,6 @@ export default function BlackSubscribe() {
<A href={language.route("/legal/terms-of-service")}>{i18n.t("black.finePrint.terms")}</A>
</p>
</section>
</Show>
</>
)
}

View File

@@ -1,21 +1,16 @@
import { A, createAsync, query, useSearchParams } from "@solidjs/router"
import { A, useSearchParams } from "@solidjs/router"
import { Title } from "@solidjs/meta"
import { createMemo, createSignal, For, Match, onMount, Show, Switch } from "solid-js"
import { PlanIcon, plans } from "./common"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { Resource } from "@opencode-ai/console-resource"
const getPaused = query(async () => {
"use server"
return Resource.App.stage === "production"
}, "black.paused")
const paused = true
export default function Black() {
const [params] = useSearchParams()
const i18n = useI18n()
const language = useLanguage()
const paused = createAsync(() => getPaused())
const [selected, setSelected] = createSignal<string | null>((params.plan as string) || null)
const [mounted, setMounted] = createSignal(false)
const selectedPlan = createMemo(() => plans.find((p) => p.id === selected()))
@@ -49,7 +44,7 @@ export default function Black() {
<>
<Title>{i18n.t("black.title")}</Title>
<section data-slot="cta">
<Show when={!paused()} fallback={<p data-slot="paused">{i18n.t("black.paused")}</p>}>
<Show when={!paused} fallback={<p data-slot="paused">{i18n.t("black.paused")}</p>}>
<Switch>
<Match when={!selected()}>
<div data-slot="pricing">
@@ -113,7 +108,7 @@ export default function Black() {
</Match>
</Switch>
</Show>
<Show when={!paused()}>
<Show when={!paused}>
<p data-slot="fine-print" style={{ "view-transition-name": "fine-print" }}>
{i18n.t("black.finePrint.beforeTerms")} ·{" "}
<A href={language.route("/legal/terms-of-service")}>{i18n.t("black.finePrint.terms")}</A>

View File

@@ -92,7 +92,6 @@ export async function handler(
const stickyProvider = await stickyTracker?.get()
const authInfo = await authenticate(modelInfo)
const billingSource = validateBilling(authInfo, modelInfo)
logger.metric({ source: billingSource })
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
const providerInfo = selectProvider(

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.2.15",
"version": "1.2.14",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.2.15",
"version": "1.2.14",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -13,11 +13,7 @@ export default {
url.pathname !== "/zen/v1/chat/completions" &&
url.pathname !== "/zen/v1/messages" &&
url.pathname !== "/zen/v1/responses" &&
!url.pathname.startsWith("/zen/v1/models/") &&
url.pathname !== "/zen/go/v1/chat/completions" &&
url.pathname !== "/zen/go/v1/messages" &&
url.pathname !== "/zen/go/v1/responses" &&
!url.pathname.startsWith("/zen/go/v1/models/")
!url.pathname.startsWith("/zen/v1/models/")
)
return

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.2.15",
"version": "1.2.14",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.2.15",
"version": "1.2.14",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -8,7 +8,7 @@ export const SIDECAR_BINARIES: Array<{ rustTarget: string; ocBinary: string; ass
},
{
rustTarget: "x86_64-apple-darwin",
ocBinary: "opencode-darwin-x64-baseline",
ocBinary: "opencode-darwin-x64",
assetExt: "zip",
},
{

View File

@@ -4,13 +4,10 @@ use process_wrap::tokio::CommandWrap;
use process_wrap::tokio::ProcessGroup;
#[cfg(windows)]
use process_wrap::tokio::{CommandWrapper, JobObject, KillOnDrop};
use std::collections::HashMap;
#[cfg(unix)]
use std::os::unix::process::ExitStatusExt;
use std::path::Path;
use std::process::Stdio;
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::{process::Stdio, time::Duration};
use tauri::{AppHandle, Manager, path::BaseDirectory};
use tauri_specta::Event;
use tokio::{
@@ -42,7 +39,6 @@ impl CommandWrapper for WinCreationFlags {
const CLI_INSTALL_DIR: &str = ".opencode/bin";
const CLI_BINARY_NAME: &str = "opencode";
const SHELL_ENV_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(serde::Deserialize, Debug)]
pub struct ServerConfig {
@@ -236,133 +232,6 @@ fn shell_escape(input: &str) -> String {
escaped
}
fn parse_shell_env(stdout: &[u8]) -> HashMap<String, String> {
String::from_utf8_lossy(stdout)
.split('\0')
.filter_map(|line| {
if line.is_empty() {
return None;
}
let (key, value) = line.split_once('=')?;
if key.is_empty() {
return None;
}
Some((key.to_string(), value.to_string()))
})
.collect()
}
fn command_output_with_timeout(
mut cmd: std::process::Command,
timeout: Duration,
) -> std::io::Result<Option<std::process::Output>> {
let mut child = cmd.spawn()?;
let start = Instant::now();
loop {
if child.try_wait()?.is_some() {
return child.wait_with_output().map(Some);
}
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
return Ok(None);
}
std::thread::sleep(Duration::from_millis(25));
}
}
enum ShellEnvProbe {
Loaded(HashMap<String, String>),
Timeout,
Unavailable,
}
fn probe_shell_env(shell: &str, mode: &str) -> ShellEnvProbe {
let mut cmd = std::process::Command::new(shell);
cmd.args([mode, "-c", "env -0"]);
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::null());
let output = match command_output_with_timeout(cmd, SHELL_ENV_TIMEOUT) {
Ok(Some(output)) => output,
Ok(None) => return ShellEnvProbe::Timeout,
Err(error) => {
tracing::debug!(shell, mode, ?error, "Shell env probe failed");
return ShellEnvProbe::Unavailable;
}
};
if !output.status.success() {
tracing::debug!(shell, mode, "Shell env probe exited with non-zero status");
return ShellEnvProbe::Unavailable;
}
let env = parse_shell_env(&output.stdout);
if env.is_empty() {
tracing::debug!(shell, mode, "Shell env probe returned empty env");
return ShellEnvProbe::Unavailable;
}
ShellEnvProbe::Loaded(env)
}
fn is_nushell(shell: &str) -> bool {
let shell_name = Path::new(shell)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(shell)
.to_ascii_lowercase();
shell_name == "nu" || shell_name == "nu.exe" || shell.to_ascii_lowercase().ends_with("\\nu.exe")
}
fn load_shell_env(shell: &str) -> Option<HashMap<String, String>> {
if is_nushell(shell) {
tracing::debug!(shell, "Skipping shell env probe for nushell");
return None;
}
match probe_shell_env(shell, "-il") {
ShellEnvProbe::Loaded(env) => {
tracing::info!(
shell,
env_count = env.len(),
"Loaded shell environment with -il"
);
return Some(env);
}
ShellEnvProbe::Timeout => {
tracing::warn!(shell, "Interactive shell env probe timed out");
return None;
}
ShellEnvProbe::Unavailable => {}
}
if let ShellEnvProbe::Loaded(env) = probe_shell_env(shell, "-l") {
tracing::info!(
shell,
env_count = env.len(),
"Loaded shell environment with -l"
);
return Some(env);
}
tracing::warn!(shell, "Falling back to app environment");
None
}
fn merge_shell_env(
shell_env: Option<HashMap<String, String>>,
envs: Vec<(String, String)>,
) -> Vec<(String, String)> {
let mut merged = shell_env.unwrap_or_default();
for (key, value) in envs {
merged.insert(key, value);
}
merged.into_iter().collect()
}
pub fn spawn_command(
app: &tauri::AppHandle,
args: &str,
@@ -443,7 +312,6 @@ pub fn spawn_command(
} else {
let sidecar = get_sidecar_path(app);
let shell = get_user_shell();
let envs = merge_shell_env(load_shell_env(&shell), envs);
let line = if shell.ends_with("/nu") {
format!("^\"{}\" {}", sidecar.display(), args)
@@ -452,7 +320,7 @@ pub fn spawn_command(
};
let mut cmd = Command::new(shell);
cmd.args(["-l", "-c", &line]);
cmd.args(["-il", "-c", &line]);
for (key, value) in envs {
cmd.env(key, value);
@@ -688,54 +556,3 @@ async fn read_line<F: Fn(String) -> CommandEvent + Send + Copy + 'static>(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn parse_shell_env_supports_null_delimited_pairs() {
let env = parse_shell_env(b"PATH=/usr/bin:/bin\0FOO=bar=baz\0\0");
assert_eq!(env.get("PATH"), Some(&"/usr/bin:/bin".to_string()));
assert_eq!(env.get("FOO"), Some(&"bar=baz".to_string()));
}
#[test]
fn parse_shell_env_ignores_invalid_entries() {
let env = parse_shell_env(b"INVALID\0=empty\0OK=1\0");
assert_eq!(env.len(), 1);
assert_eq!(env.get("OK"), Some(&"1".to_string()));
}
#[test]
fn merge_shell_env_keeps_explicit_overrides() {
let mut shell_env = HashMap::new();
shell_env.insert("PATH".to_string(), "/shell/path".to_string());
shell_env.insert("HOME".to_string(), "/tmp/home".to_string());
let merged = merge_shell_env(
Some(shell_env),
vec![
("PATH".to_string(), "/desktop/path".to_string()),
("OPENCODE_CLIENT".to_string(), "desktop".to_string()),
],
)
.into_iter()
.collect::<HashMap<_, _>>();
assert_eq!(merged.get("PATH"), Some(&"/desktop/path".to_string()));
assert_eq!(merged.get("HOME"), Some(&"/tmp/home".to_string()));
assert_eq!(merged.get("OPENCODE_CLIENT"), Some(&"desktop".to_string()));
}
#[test]
fn is_nushell_handles_path_and_binary_name() {
assert!(is_nushell("nu"));
assert!(is_nushell("/opt/homebrew/bin/nu"));
assert!(is_nushell("C:\\Program Files\\nu.exe"));
assert!(!is_nushell("/bin/zsh"));
}
}

View File

@@ -179,18 +179,6 @@ fn resolve_app_path(app_name: &str) -> Option<String> {
}
}
#[tauri::command]
#[specta::specta]
fn open_in_powershell(path: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
return os::windows::open_in_powershell(path);
}
#[cfg(not(target_os = "windows"))]
Err("PowerShell is only supported on Windows".to_string())
}
#[cfg(target_os = "macos")]
fn check_macos_app(app_name: &str) -> bool {
// Check common installation locations
@@ -385,8 +373,7 @@ fn make_specta_builder() -> tauri_specta::Builder<tauri::Wry> {
markdown::parse_markdown_command,
check_app_exists,
wsl_path,
resolve_app_path,
open_in_powershell
resolve_app_path
])
.events(tauri_specta::collect_events![
LoadingWindowComplete,

View File

@@ -6,12 +6,9 @@ use std::{
};
use windows_sys::Win32::{
Foundation::ERROR_SUCCESS,
System::{
Registry::{
RegGetValueW, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, REG_EXPAND_SZ, REG_SZ,
RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ,
},
Threading::{CREATE_NEW_CONSOLE, CREATE_NO_WINDOW},
System::Registry::{
HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, REG_EXPAND_SZ, REG_SZ, RRF_RT_REG_EXPAND_SZ,
RRF_RT_REG_SZ, RegGetValueW,
},
};
@@ -313,7 +310,7 @@ pub fn resolve_windows_app_path(app_name: &str) -> Option<String> {
let resolve_where = |query: &str| -> Option<String> {
let output = Command::new("where")
.creation_flags(CREATE_NO_WINDOW)
.creation_flags(0x08000000)
.arg(query)
.output()
.ok()?;
@@ -440,24 +437,3 @@ pub fn resolve_windows_app_path(app_name: &str) -> Option<String> {
None
}
pub fn open_in_powershell(path: String) -> Result<(), String> {
let path = PathBuf::from(path);
let dir = if path.is_dir() {
path
} else if let Some(parent) = path.parent() {
parent.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| format!("Failed to determine current directory: {e}"))?
};
Command::new("powershell.exe")
.creation_flags(CREATE_NEW_CONSOLE)
.current_dir(dir)
.args(["-NoExit"])
.spawn()
.map_err(|e| format!("Failed to start PowerShell: {e}"))?;
Ok(())
}

View File

@@ -18,7 +18,6 @@ export const commands = {
checkAppExists: (appName: string) => __TAURI_INVOKE<boolean>("check_app_exists", { appName }),
wslPath: (path: string, mode: "windows" | "linux" | null) => __TAURI_INVOKE<string>("wsl_path", { path, mode }),
resolveAppPath: (appName: string) => __TAURI_INVOKE<string | null>("resolve_app_path", { appName }),
openInPowershell: (path: string) => __TAURI_INVOKE<null>("open_in_powershell", { path }),
};
/** Events */

View File

@@ -118,6 +118,7 @@ const createPlatform = (): Platform => {
async openPath(path: string, app?: string) {
const os = ostype()
if (os === "windows") {
const resolvedApp = (app && (await commands.resolveAppPath(app))) || app
const resolvedPath = await (async () => {
if (window.__OPENCODE__?.wsl) {
const converted = await commands.wslPath(path, "windows").catch(() => null)
@@ -126,16 +127,6 @@ const createPlatform = (): Platform => {
return path
})()
const resolvedApp = (app && (await commands.resolveAppPath(app))) || app
const isPowershell = (value?: string) => {
if (!value) return false
const name = value.toLowerCase().replaceAll("/", "\\").split("\\").pop()
return name === "powershell" || name === "powershell.exe"
}
if (isPowershell(resolvedApp)) {
await commands.openInPowershell(resolvedPath)
return
}
return openerOpenPath(resolvedPath, resolvedApp)
}
return openerOpenPath(path, app)

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.2.15",
"version": "1.2.14",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -2,8 +2,7 @@ import { FileDiff, Message, Model, Part, Session, SessionStatus, UserMessage } f
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { SessionReview } from "@opencode-ai/ui/session-review"
import { DataProvider } from "@opencode-ai/ui/context"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool"
import { createAsync, query, useParams } from "@solidjs/router"
import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
@@ -22,14 +21,12 @@ import NotFound from "../[...404]"
import { Tabs } from "@opencode-ai/ui/tabs"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { Diff as SSRDiff } from "@opencode-ai/ui/diff-ssr"
import { FileSSR } from "@opencode-ai/ui/file-ssr"
import { clientOnly } from "@solidjs/start"
import { type IconName } from "@opencode-ai/ui/icons/provider"
import { Meta, Title } from "@solidjs/meta"
import { Base64 } from "js-base64"
const ClientOnlyDiff = clientOnly(() => import("@opencode-ai/ui/diff").then((m) => ({ default: m.Diff })))
const ClientOnlyCode = clientOnly(() => import("@opencode-ai/ui/code").then((m) => ({ default: m.Code })))
const ClientOnlyWorkerPoolProvider = clientOnly(() =>
import("@opencode-ai/ui/pierre/worker").then((m) => ({
default: (props: { children: any }) => (
@@ -218,252 +215,244 @@ export default function () {
<Meta property="og:image" content={ogImage()} />
<Meta name="twitter:image" content={ogImage()} />
<ClientOnlyWorkerPoolProvider>
<DiffComponentProvider component={ClientOnlyDiff}>
<CodeComponentProvider component={ClientOnlyCode}>
<DataProvider data={data()} directory={info().directory}>
{iife(() => {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
})
const messages = createMemo(() =>
data().sessionID
? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort(
(a, b) => a.time.created - b.time.created,
)
: [],
)
const firstUserMessage = createMemo(() => messages().at(0))
const activeMessage = createMemo(
() => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
)
function setActiveMessage(message: UserMessage | undefined) {
if (message) {
setStore("messageId", message.id)
} else {
setStore("messageId", undefined)
}
<FileComponentProvider component={FileSSR}>
<DataProvider data={data()} directory={info().directory}>
{iife(() => {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
})
const messages = createMemo(() =>
data().sessionID
? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort(
(a, b) => a.time.created - b.time.created,
)
: [],
)
const firstUserMessage = createMemo(() => messages().at(0))
const activeMessage = createMemo(
() => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
)
function setActiveMessage(message: UserMessage | undefined) {
if (message) {
setStore("messageId", message.id)
} else {
setStore("messageId", undefined)
}
const provider = createMemo(() => activeMessage()?.model?.providerID)
const modelID = createMemo(() => activeMessage()?.model?.modelID)
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
const diffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
const splitDiffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload_split[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
}
const provider = createMemo(() => activeMessage()?.model?.providerID)
const modelID = createMemo(() => activeMessage()?.model?.modelID)
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
const diffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
const splitDiffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload_split[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
const title = () => (
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2 sm:flex-row sm:gap-4 sm:items-center sm:h-8 justify-start self-stretch">
<div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base w-fit">
<Mark class="shrink-0 w-3 my-0.5" />
<div class="text-12-mono text-text-base">v{info().version}</div>
</div>
<div class="flex gap-4 items-center">
<div class="flex gap-2 items-center">
<ProviderIcon
id={provider() as IconName}
class="size-3.5 shrink-0 text-icon-strong-base"
/>
<div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
</div>
<div class="text-12-regular text-text-weaker">
{DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
</div>
</div>
const title = () => (
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2 sm:flex-row sm:gap-4 sm:items-center sm:h-8 justify-start self-stretch">
<div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base w-fit">
<Mark class="shrink-0 w-3 my-0.5" />
<div class="text-12-mono text-text-base">v{info().version}</div>
</div>
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
</div>
)
const turns = () => (
<div class="relative mt-2 pb-8 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
<div class="px-4 py-6">{title()}</div>
<div class="flex flex-col gap-15 items-start justify-start mt-4">
<For each={messages()}>
{(message) => (
<SessionTurn
sessionID={data().sessionID}
messageID={message.id}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
container: "px-4",
}}
/>
)}
</For>
</div>
<div class="px-4 flex items-center justify-center pt-20 pb-8 shrink-0">
<Logo class="w-58.5 opacity-12" />
<div class="flex gap-4 items-center">
<div class="flex gap-2 items-center">
<ProviderIcon
id={provider() as IconName}
class="size-3.5 shrink-0 text-icon-strong-base"
/>
<div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
</div>
<div class="text-12-regular text-text-weaker">
{DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
</div>
</div>
</div>
)
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
</div>
)
const wide = createMemo(() => diffs().length === 0)
const turns = () => (
<div class="relative mt-2 pb-8 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
<div class="px-4 py-6">{title()}</div>
<div class="flex flex-col gap-15 items-start justify-start mt-4">
<For each={messages()}>
{(message) => (
<SessionTurn
sessionID={data().sessionID}
messageID={message.id}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
container: "px-4",
}}
/>
)}
</For>
</div>
<div class="px-4 flex items-center justify-center pt-20 pb-8 shrink-0">
<Logo class="w-58.5 opacity-12" />
</div>
</div>
)
return (
<div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
<header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
<div class="">
<a href="https://opencode.ai">
<Mark />
</a>
</div>
<div class="flex gap-3 items-center">
<IconButton
as={"a"}
href="https://github.com/anomalyco/opencode"
target="_blank"
icon="github"
variant="ghost"
/>
<IconButton
as={"a"}
href="https://opencode.ai/discord"
target="_blank"
icon="discord"
variant="ghost"
/>
</div>
</header>
<div class="select-text flex flex-col flex-1 min-h-0">
const wide = createMemo(() => diffs().length === 0)
return (
<div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
<header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
<div class="">
<a href="https://opencode.ai">
<Mark />
</a>
</div>
<div class="flex gap-3 items-center">
<IconButton
as={"a"}
href="https://github.com/anomalyco/opencode"
target="_blank"
icon="github"
variant="ghost"
/>
<IconButton
as={"a"}
href="https://opencode.ai/discord"
target="_blank"
icon="discord"
variant="ghost"
/>
</div>
</header>
<div class="select-text flex flex-col flex-1 min-h-0">
<div
classList={{
"hidden w-full flex-1 min-h-0": true,
"md:flex": wide(),
"lg:flex": !wide(),
}}
>
<div
classList={{
"hidden w-full flex-1 min-h-0": true,
"md:flex": wide(),
"lg:flex": !wide(),
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full": true,
}}
>
<div
classList={{
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full": true,
"w-full flex justify-start items-start min-w-0 px-6": true,
}}
>
<div
classList={{
"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">
<Show when={messages().length > 1}>
<MessageNav
class="sticky top-0 shrink-0 py-2 pl-4"
messages={messages()}
current={activeMessage()}
size="compact"
onMessageSelect={setActiveMessage}
/>
</Show>
<SessionTurn
sessionID={data().sessionID}
messageID={store.messageId ?? firstUserMessage()!.id!}
classes={{
root: "grow",
content: "flex flex-col justify-between",
container: "w-full pb-20 px-6",
}}
>
{title()}
</div>
<div class="flex items-start justify-start h-full min-h-0">
<Show when={messages().length > 1}>
<MessageNav
class="sticky top-0 shrink-0 py-2 pl-4"
messages={messages()}
current={activeMessage()}
size="compact"
onMessageSelect={setActiveMessage}
/>
</Show>
<SessionTurn
sessionID={data().sessionID}
messageID={store.messageId ?? firstUserMessage()!.id!}
classes={{
root: "grow",
content: "flex flex-col justify-between",
container: "w-full pb-20 px-6",
}}
>
<div
classList={{ "w-full flex items-center justify-center pb-8 shrink-0": true }}
>
<Logo class="w-58.5 opacity-12" />
</div>
</SessionTurn>
</div>
<div classList={{ "w-full flex items-center justify-center pb-8 shrink-0": true }}>
<Logo class="w-58.5 opacity-12" />
</div>
</SessionTurn>
</div>
<Show when={diffs().length > 0}>
<DiffComponentProvider component={SSRDiff}>
<div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
</div>
<Show when={diffs().length > 0}>
<div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
<SessionReview
class="@4xl:hidden"
diffs={diffs()}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
/>
<SessionReview
split
class="hidden @4xl:flex"
diffs={splitDiffs()}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
/>
</div>
</Show>
</div>
<Switch>
<Match when={diffs().length > 0}>
<Tabs classList={{ "md:hidden": wide(), "lg:hidden": !wide() }}>
<Tabs.List>
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
Session
</Tabs.Trigger>
<Tabs.Trigger
value="review"
class="w-1/2 !border-r-0"
classes={{ button: "w-full" }}
>
{diffs().length} Files Changed
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="session" class="!overflow-hidden">
{turns()}
</Tabs.Content>
<Tabs.Content
forceMount
value="review"
class="!overflow-hidden hidden data-[selected]:block"
>
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
<SessionReview
class="@4xl:hidden"
diffs={diffs()}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
/>
<SessionReview
split
class="hidden @4xl:flex"
diffs={splitDiffs()}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
header: "px-4",
container: "px-4",
}}
/>
</div>
</DiffComponentProvider>
</Show>
</div>
<Switch>
<Match when={diffs().length > 0}>
<Tabs classList={{ "md:hidden": wide(), "lg:hidden": !wide() }}>
<Tabs.List>
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
Session
</Tabs.Trigger>
<Tabs.Trigger
value="review"
class="w-1/2 !border-r-0"
classes={{ button: "w-full" }}
>
{diffs().length} Files Changed
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="session" class="!overflow-hidden">
{turns()}
</Tabs.Content>
<Tabs.Content
forceMount
value="review"
class="!overflow-hidden hidden data-[selected]:block"
>
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
<DiffComponentProvider component={SSRDiff}>
<SessionReview
diffs={diffs()}
classes={{
root: "pb-20",
header: "px-4",
container: "px-4",
}}
/>
</DiffComponentProvider>
</div>
</Tabs.Content>
</Tabs>
</Match>
<Match when={true}>
<div
classList={{ "!overflow-hidden": true, "md:hidden": wide(), "lg:hidden": !wide() }}
>
{turns()}
</div>
</Match>
</Switch>
</div>
</Tabs.Content>
</Tabs>
</Match>
<Match when={true}>
<div
classList={{ "!overflow-hidden": true, "md:hidden": wide(), "lg:hidden": !wide() }}
>
{turns()}
</div>
</Match>
</Switch>
</div>
)
})}
</DataProvider>
</CodeComponentProvider>
</DiffComponentProvider>
</div>
)
})}
</DataProvider>
</FileComponentProvider>
</ClientOnlyWorkerPoolProvider>
</>
)

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.2.15"
version = "1.2.14"
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.2.15/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.14/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.14/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.14/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.2.15/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.14/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.2.15/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.14/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.2.15",
"version": "1.2.14",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,7 +0,0 @@
CREATE TABLE `workspace` (
`id` text PRIMARY KEY,
`branch` text,
`project_id` text NOT NULL,
`config` text NOT NULL,
CONSTRAINT `fk_workspace_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
);

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.2.15",
"version": "1.2.14",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -43,7 +43,6 @@
"@types/mime-types": "3.0.1",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"@types/which": "3.0.4",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.12-a5629fb",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
@@ -90,8 +89,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.83",
"@opentui/solid": "0.1.83",
"@opentui/core": "0.1.81",
"@opentui/solid": "0.1.81",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -128,7 +127,6 @@
"ulid": "catalog:",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",

View File

@@ -56,7 +56,7 @@ const migrations = await Promise.all(
)
console.log(`Loaded ${migrations.length} migrations`)
const singleFlag = process.argv.includes("--single")
const singleFlag = process.argv.includes("--single") || (!!process.env.CI && !process.argv.includes("--all"))
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
@@ -103,11 +103,6 @@ const allTargets: {
os: "darwin",
arch: "x64",
},
{
os: "darwin",
arch: "x64",
avx2: false,
},
{
os: "win32",
arch: "x64",

View File

@@ -2,62 +2,46 @@
import { z } from "zod"
import { Config } from "../src/config/config"
import { TuiConfig } from "../src/config/tui"
function generate(schema: z.ZodType) {
const result = z.toJSONSchema(schema, {
io: "input", // Generate input shape (treats optional().default() as not required)
/**
* We'll use the `default` values of the field as the only value in `examples`.
* This will ensure no docs are needed to be read, as the configuration is
* self-documenting.
*
* See https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.9.5
*/
override(ctx) {
const schema = ctx.jsonSchema
const file = process.argv[2]
console.log(file)
// Preserve strictness: set additionalProperties: false for objects
if (
schema &&
typeof schema === "object" &&
schema.type === "object" &&
schema.additionalProperties === undefined
) {
schema.additionalProperties = false
const result = z.toJSONSchema(Config.Info, {
io: "input", // Generate input shape (treats optional().default() as not required)
/**
* We'll use the `default` values of the field as the only value in `examples`.
* This will ensure no docs are needed to be read, as the configuration is
* self-documenting.
*
* See https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.9.5
*/
override(ctx) {
const schema = ctx.jsonSchema
// Preserve strictness: set additionalProperties: false for objects
if (schema && typeof schema === "object" && schema.type === "object" && schema.additionalProperties === undefined) {
schema.additionalProperties = false
}
// Add examples and default descriptions for string fields with defaults
if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) {
if (!schema.examples) {
schema.examples = [schema.default]
}
// Add examples and default descriptions for string fields with defaults
if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) {
if (!schema.examples) {
schema.examples = [schema.default]
}
schema.description = [schema.description || "", `default: \`${schema.default}\``]
.filter(Boolean)
.join("\n\n")
.trim()
}
},
}) as Record<string, unknown> & {
allowComments?: boolean
allowTrailingCommas?: boolean
}
// used for json lsps since config supports jsonc
result.allowComments = true
result.allowTrailingCommas = true
return result
schema.description = [schema.description || "", `default: \`${schema.default}\``]
.filter(Boolean)
.join("\n\n")
.trim()
}
},
}) as Record<string, unknown> & {
allowComments?: boolean
allowTrailingCommas?: boolean
}
const configFile = process.argv[2]
const tuiFile = process.argv[3]
// used for json lsps since config supports jsonc
result.allowComments = true
result.allowTrailingCommas = true
console.log(configFile)
await Bun.write(configFile, JSON.stringify(generate(Config.Info), null, 2))
if (tuiFile) {
console.log(tuiFile)
await Bun.write(tuiFile, JSON.stringify(generate(TuiConfig.Info), null, 2))
}
await Bun.write(file, JSON.stringify(result, null, 2))

View File

@@ -63,7 +63,6 @@ export namespace Agent {
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
edit: "ask",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",

View File

@@ -13,7 +13,6 @@ import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process"
import { text } from "node:stream/consumers"
import { setTimeout as sleep } from "node:timers/promises"
type PluginAuth = NonNullable<Hooks["auth"]>
@@ -39,7 +38,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string):
const method = plugin.auth.methods[index]
// Handle prompts for all auth types
await sleep(10)
await Bun.sleep(10)
const inputs: Record<string, string> = {}
if (method.prompts) {
for (const prompt of method.prompts) {

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