sync
perso/opencode-openchamber/pipeline/head There was a failure building this commit

This commit is contained in:
Julien Cabillot
2026-04-27 16:09:15 -04:00
parent 863a971330
commit 447ec8ab99
720 changed files with 118619 additions and 48612 deletions
+76
View File
@@ -123,3 +123,79 @@ jobs:
name: dmg-${{ inputs.macos_version }}-arm64
path: artifacts/*.dmg
retention-days: 7
build-macos-dmg-arm64-electron:
name: Build Electron DMG (arm64, ${{ inputs.macos_version }})
runs-on: ${{ inputs.macos_version }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup bun
uses: oven-sh/setup-bun@v2
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Install Apple Certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
KEYCHAIN_PATH=$RUNNER_TEMP/electron-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12
security import $RUNNER_TEMP/certificate.p12 \
-P "$APPLE_CERTIFICATE_PASSWORD" \
-A -t cert -f pkcs12 \
-k "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
- name: Build Electron app (arm64)
working-directory: packages/electron
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
ELECTRON_BUILDER_ARCH: arm64
run: |
bun run build:web-assets
bun run bundle:main
bun run rebuild:native
./node_modules/.bin/electron-builder --mac --arm64 --publish=never
- name: Prepare DMG artifact
run: |
set -euo pipefail
mkdir -p artifacts
DMG_PATH="packages/electron/dist/*.dmg"
if ls $DMG_PATH 1> /dev/null 2>&1; then
DMG_FILE=$(ls $DMG_PATH | head -n 1)
DMG_NAME="OpenChamber_Electron_${{ inputs.macos_version }}_arm64.dmg"
cp "$DMG_FILE" "artifacts/$DMG_NAME"
else
echo "Error: DMG file not found at $DMG_PATH"
exit 1
fi
- name: Upload DMG artifact
uses: actions/upload-artifact@v4
with:
name: dmg-electron-${{ inputs.macos_version }}-arm64
path: artifacts/*.dmg
retention-days: 7
+176 -1
View File
@@ -494,11 +494,174 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-desktop-electron-macos:
needs: create-release
runs-on: macos-26
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
arch: arm64
platform: darwin-aarch64
- target: x86_64-apple-darwin
arch: x64
platform: darwin-x86_64
steps:
- uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v2
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Install Apple Certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
KEYCHAIN_PATH=$RUNNER_TEMP/electron-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12
security import $RUNNER_TEMP/certificate.p12 \
-P "$APPLE_CERTIFICATE_PASSWORD" \
-A -t cert -f pkcs12 \
-k "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
- name: Build Electron app
working-directory: packages/electron
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# rebuild-native.mjs reads this to target the right arch when
# cross-building (runner is arm64; x64 matrix needs the hint).
ELECTRON_BUILDER_ARCH: ${{ matrix.arch }}
run: |
bun run build:web-assets
bun run bundle:main
# npmRebuild=false in package.json, so electron-builder won't
# recompile native deps on its own — we must rebuild against the
# target Electron ABI before packaging, otherwise better-sqlite3/
# node-pty/bun-pty crash on require inside the packaged app.
bun run rebuild:native
./node_modules/.bin/electron-builder --mac --${{ matrix.arch }} --publish=never
- name: Verify signature + entitlements + notarization
run: |
set -euo pipefail
APP_DIR="packages/electron/dist/mac"
[ -d "packages/electron/dist/mac-arm64" ] && APP_DIR="packages/electron/dist/mac-arm64"
APP_PATH=$(find "$APP_DIR" -maxdepth 2 -name "*.app" -print -quit)
if [ -z "$APP_PATH" ]; then
echo "Error: .app not found under packages/electron/dist/mac*"
ls -la packages/electron/dist/
exit 1
fi
echo "Verifying $APP_PATH"
codesign -vv --deep --strict "$APP_PATH"
# Require hardened runtime
CS_INFO=$(codesign -dv --verbose=4 "$APP_PATH" 2>&1)
echo "$CS_INFO"
if ! echo "$CS_INFO" | grep -q "flags=.*runtime"; then
echo "Error: hardened runtime flag missing"
exit 1
fi
# Require notary ticket stapled
xcrun stapler validate "$APP_PATH"
ENTITLEMENTS=$(codesign -d --entitlements :- "$APP_PATH" 2>&1 || true)
if echo "$ENTITLEMENTS" | grep -q "com.apple.security.app-sandbox"; then
echo "Error: app sandbox entitlement is present"
exit 1
fi
for key in \
com.apple.security.cs.allow-jit \
com.apple.security.cs.allow-unsigned-executable-memory \
com.apple.security.cs.disable-library-validation
do
if ! echo "$ENTITLEMENTS" | grep -q "<key>$key</key>"; then
echo "Error: required entitlement missing: $key"
exit 1
fi
done
- name: Upload DMG / ZIP / blockmaps to release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.create-release.outputs.version }}
files: |
packages/electron/dist/*.dmg
packages/electron/dist/*.zip
packages/electron/dist/*.blockmap
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload per-arch latest-mac.yml for merge
uses: actions/upload-artifact@v4
with:
name: latest-yml-${{ matrix.target }}
path: packages/electron/dist/latest-mac.yml
retention-days: 1
combine-electron-manifests:
needs: [create-release, build-desktop-electron-macos]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Download per-arch latest-mac.yml
uses: actions/download-artifact@v4
with:
pattern: latest-yml-*-apple-darwin
path: artifacts
- name: Finalize combined latest-mac.yml
env:
LATEST_YML_DIR: ${{ github.workspace }}/artifacts
GH_REPO: ${{ github.repository }}
OPENCHAMBER_VERSION: ${{ needs.create-release.outputs.version }}
run: node packages/electron/scripts/finalize-latest-yml.mjs
- name: Upload combined latest-mac.yml to release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.create-release.outputs.version }}
files: ${{ runner.temp }}/latest-mac.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
finalize-release:
needs: [create-release, build-desktop-macos, publish-npm, combine-manifests]
needs: [create-release, build-desktop-macos, build-desktop-electron-macos, publish-npm, combine-manifests, combine-electron-manifests]
runs-on: ubuntu-latest
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
DISCORD_UPDATE_ROLE_ID: ${{ secrets.DISCORD_UPDATE_ROLE_ID }}
steps:
- name: Publish release
uses: softprops/action-gh-release@v2
@@ -514,11 +677,14 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.create-release.outputs.version }}
REPOSITORY: ${{ github.repository }}
UPDATE_ROLE_ID: ${{ env.DISCORD_UPDATE_ROLE_ID }}
run: |
node - <<'NODE'
(async () => {
const tag = `v${process.env.VERSION}`;
const repo = process.env.REPOSITORY;
const rawRoleId = (process.env.UPDATE_ROLE_ID || '').trim();
const updateRoleId = /^\d+$/.test(rawRoleId) ? rawRoleId : '';
const releaseRes = await fetch(`https://api.github.com/repos/${repo}/releases/tags/${tag}`, {
headers: {
@@ -534,9 +700,18 @@ jobs:
const release = await releaseRes.json();
const description = (release.body || `OpenChamber ${tag} released.`).slice(0, 4096);
const mention = updateRoleId ? `<@&${updateRoleId}>` : '';
const payload = {
username: 'OpenChamber Releases',
...(mention ? { content: mention } : {}),
...(updateRoleId
? {
allowed_mentions: {
roles: [updateRoleId],
},
}
: {}),
embeds: [
{
title: release.name || `OpenChamber ${tag}`,
+1
View File
@@ -28,6 +28,7 @@ local-dev*
*.sw?
.opencode/plans/*
.hive
docs/personal/*
# Build outputs
build/
+376
View File
@@ -0,0 +1,376 @@
{
"name": ".opencode",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@opencode-ai/plugin": "1.4.10"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@opencode-ai/plugin": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.4.10.tgz",
"integrity": "sha512-35Za2LT2oNWnBoonmPjN1Z9PB4+ir2a6GbZ3nIZQQL/96mqzTRkT1FqUkQc3bdMmfT1R1rqOd5aMzkIXMqC7dA==",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.4.10",
"effect": "4.0.0-beta.48",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.1.100",
"@opentui/solid": ">=0.1.100"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/solid": {
"optional": true
}
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.4.10.tgz",
"integrity": "sha512-Yaddcs/COp0hwiCxgobSZyDUN0nHgkEFL4bG0BQxwd52SGAysOr6A6L0ihfkuhVx0kbi9eXWgZk4ydNOrnur5w==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/effect": {
"version": "4.0.0-beta.48",
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz",
"integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"fast-check": "^4.6.0",
"find-my-way-ts": "^0.1.6",
"ini": "^6.0.0",
"kubernetes-types": "^1.30.0",
"msgpackr": "^1.11.9",
"multipasta": "^0.2.7",
"toml": "^4.1.1",
"uuid": "^13.0.0",
"yaml": "^2.8.3"
}
},
"node_modules/fast-check": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.6.0.tgz",
"integrity": "sha512-h7H6Dm0Fy+H4ciQYFxFjXnXkzR2kr9Fb22c0UBpHnm59K2zpr2t13aPTHlltFiNT6zuxp6HMPAVVvgur4BLdpA==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^8.0.0"
},
"engines": {
"node": ">=12.17.0"
}
},
"node_modules/find-my-way-ts": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
"license": "MIT"
},
"node_modules/ini": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
"license": "ISC",
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/kubernetes-types": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
"license": "Apache-2.0"
},
"node_modules/msgpackr": {
"version": "1.11.9",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.9.tgz",
"integrity": "sha512-FkoAAyyA6HM8wL882EcEyFZ9s7hVADSwG9xrVx3dxxNQAtgADTrJoEWivID82Iv1zWDsv/OtbrrcZAzGzOMdNw==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
}
},
"node_modules/multipasta": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
"license": "MIT"
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pure-rand": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/toml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zod": {
"version": "4.1.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
@@ -0,0 +1,128 @@
---
name: locale-ui-patterns
description: Use when creating or modifying OpenChamber UI text, labels, buttons, placeholders, aria labels, empty states, toasts, dialogs, settings copy, navigation labels, or any user-facing strings.
---
# Locale UI Patterns
## Core Rule
User-facing UI text must go through `@/lib/i18n`; do not hardcode English strings in components.
Use this skill for any React UI change that adds or edits visible text, accessible labels, placeholders, tooltips, toasts, dialogs, settings labels, navigation labels, or empty/error states.
## Required Flow
1. Add or reuse a key in `packages/ui/src/lib/i18n/messages/en.ts`.
2. Add the same key to every non-English dictionary in `packages/ui/src/lib/i18n/messages/`.
3. In components, call `const { t } = useI18n()` from `@/lib/i18n` and render `t('key')`.
4. For locale names or language picker labels, use `label(locale)` from `useI18n()`.
5. Keep locale state in `packages/ui/src/lib/i18n/*`; do not add locale fields to broad stores like `useUIStore`.
6. Do not remount the app to update language. Components must re-render through `useI18n()`.
## Component Usage Rules
- Import from `@/lib/i18n`, not deep files.
- Keep `t(...)` calls inside React render/hook scope so locale changes re-render text.
- Do not resolve translated text at module scope.
- For static option arrays, store `labelKey` / `descriptionKey`; resolve with `t(...)` inside the component.
- For non-React helpers, pass translated strings in from the component or pass `t` explicitly.
## Key Style
Use stable semantic keys, not English text as keys.
Keys should describe location + UI role + meaning. They should not encode current copy wording.
Use existing nearby naming when extending a surface. If no nearby pattern exists, choose a short path that mirrors the UI ownership.
Namespaces like `layout.*`, `settings.*`, `chat.*`, `git.*`, `session.*`, `toast.*`, and `dialog.*` are examples, not a fixed exhaustive list.
Good:
```ts
'settings.appearance.language.label': 'Language'
'layout.mainTab.chat': 'Chat'
'chat.input.placeholder': 'Ask OpenChamber...'
```
Bad:
```ts
'Language': 'Language'
'chatLabel': 'Chat'
'askOpenChamberDotDotDot': 'Ask OpenChamber...'
```
Avoid overly generic keys unless the text is truly global and context-independent. Prefer specific keys when button meaning can vary by surface.
## Parameters
Use `{name}` placeholders for dynamic values.
```ts
'toast.language.changed': 'Language changed to {language}'
```
```tsx
t('toast.language.changed', { language: label(locale) })
```
Do not pass grammar fragments as params. Never use params like `{suffix}`, `{plural}`, `{article}`, `{prefix}`, `{dateSuffix}`, or pieces of words/sentences.
Bad:
```tsx
t('dialog.delete.description', { count, suffix: count === 1 ? '' : 's' })
```
Good:
```tsx
count === 1
? t('dialog.delete.descriptionSingle', { count })
: t('dialog.delete.descriptionPlural', { count })
```
Plural/count-dependent text must use separate complete-message keys unless all supported locales can use one identical complete sentence. Placeholders are only for real values (`{count}`, `{name}`, `{path}`), not grammar.
Optional clauses must also be complete-message keys. Do not build a sentence by injecting a translated phrase into another translated sentence.
Bad:
```tsx
t('dialog.delete.description', {
dateLabel: date ? t('dialog.delete.dateSuffix', { date }) : '',
})
```
Good:
```tsx
date
? t('dialog.delete.descriptionWithDate', { count, date })
: t('dialog.delete.description', { count })
```
## What Counts As UI Text
- Button and menu labels
- Settings labels and descriptions
- Placeholder text
- Tooltip content
- Dialog titles/descriptions/actions
- Toast title/description/action labels
- Empty/error/loading states
- `aria-label`, `title`, image `alt` text when user-facing
## Exceptions
Do not translate:
- Product names: `OpenChamber`, `OpenCode`, `GitHub`
- Protocol/tool acronyms: `MCP`, `SSE`, `WebSocket`, `API`
- Model/provider names
- File paths, command names, environment variables
- User/generated content
## Review Checklist
- No new hardcoded user-facing English in changed UI files.
- Every new key exists in all dictionaries.
- No locale state added to broad/shared stores.
- No full app remount for locale changes.
- Locale switch preserves current UI state.
+225 -19
View File
@@ -1,88 +1,130 @@
# OpenChamber - AI Agent Reference (verified)
## Core purpose
OpenChamber provides UI runtimes (web/desktop/VS Code) for interacting with an OpenCode server (local auto-start or remote URL). UI uses HTTP + SSE via `@opencode-ai/sdk`.
## Runtime architecture (IMPORTANT)
- `Desktop` is a thin Tauri shell that starts the web server sidecar and loads the web UI from `http://127.0.0.1:<port>`.
- All backend logic lives in `packages/web/server/*` (and `packages/vscode/*` for the VS Code runtime). Desktop Rust is not a feature backend.
- Tauri is used only for stable native integrations: menu, dialog (open folder), notifications, updater, deep-links.
- `Desktop` (Electron) boots the web server **in the same Node process** as the Electron main, then loads the web UI from `http://127.0.0.1:<port>`. No sidecar subprocess.
- `Desktop` (Tauri, legacy) still spawns `openchamber-server` as a bun-compiled sidecar binary. Kept only for auto-update compatibility with existing Tauri installs.
- All backend logic lives in `packages/web/server/*` (and `packages/vscode/*` for the VS Code runtime). The native shell is not a feature backend.
- The shell is used only for stable native integrations: menu, dialog (open folder), notifications, updater, deep-links, quit confirmation.
### Desktop shell: Electron is the target, Tauri is legacy
- **New desktop work goes into `packages/electron/`.** This is the forward path.
- `packages/desktop/` (Tauri) is kept running in parallel only to preserve auto-update for existing installs until the cutover. Do **not** add features to it; do **not** port bug fixes back unless they actually affect currently-released Tauri users.
- Desktop-side changes (IPC handlers, native integrations, window/quit/notification behavior) land in `packages/electron/main.mjs` + `packages/electron/preload.mjs`. The `__TAURI__` shim exposed by the preload keeps the shared UI working against both shells, so renderer-side code should not branch on shell type.
- Electron imports the server via `@openchamber/web/server/index.js` (workspace dep) and calls `startWebUiServer({...})`. The returned handle has `getPort()` / `stop()`. Notifications flow via an `onDesktopNotification` callback injected at startup — no stdout-parsing IPC.
- Build/release: both shells ship in the same GitHub release today (`.github/workflows/release.yml`). The one-shot Tauri → Electron auto-update migration is documented in `docs/TAURI_TO_ELECTRON_CUTOVER.md`; run that when the user decides to flip.
- After the cutover ships and stabilises, `packages/desktop/` is deleted; this note collapses back to "Desktop is Electron".
## Tech stack (source of truth: `package.json`, resolved: `bun.lock`)
- Runtime/tooling: Bun (`package.json` `packageManager`), Node >=20 (`package.json` `engines`)
- UI: React, TypeScript, Vite, Tailwind v4
- State: Zustand (`packages/ui/src/stores/`)
- UI primitives: Radix UI (`package.json` deps), HeroUI (`package.json` deps), Remixicon (`package.json` deps)
- UI primitives: Base UI (`@base-ui/react`, primary source for dropdown/select/dialog/menu/tooltip/etc. — wrappers live in `packages/ui/src/components/ui/`), Radix UI (`package.json` deps, legacy usages being migrated), HeroUI (`package.json` deps), Remixicon (`package.json` deps)
- Server: Express (`packages/web/server/index.js`)
- Desktop: Tauri v2 (`packages/desktop/src-tauri/`)
- Desktop (forward): Electron 41 (`packages/electron/`)
- Desktop (legacy, maintenance-only): Tauri v2 (`packages/desktop/src-tauri/`)
- VS Code: extension + webview (`packages/vscode/`)
## Monorepo layout
Workspaces are `packages/*` (see `package.json`).
- Shared UI: `packages/ui`
- Web app + server + CLI: `packages/web`
- Desktop app (Tauri): `packages/desktop`
- Desktop shell (Electron — forward): `packages/electron`
- Desktop shell (Tauri — legacy, maintenance-only): `packages/desktop`
- VS Code extension: `packages/vscode`
## Documentation map
Before changing any mapped module, read its module documentation first.
### web
Web runtime and server implementation for OpenChamber.
#### lib
Server-side integration modules used by API routes and runtime services.
##### quota
Quota provider registry, dispatch, and provider integrations for usage endpoints.
- Module docs: `packages/web/server/lib/quota/DOCUMENTATION.md`
##### git
Git repository operations for the web server runtime.
- Module docs: `packages/web/server/lib/git/DOCUMENTATION.md`
##### github
GitHub authentication, OAuth device flow, Octokit client factory, and repository URL parsing.
- Module docs: `packages/web/server/lib/github/DOCUMENTATION.md`
##### opencode
OpenCode server integration utilities including config management, provider authentication, and UI authentication.
- Module docs: `packages/web/server/lib/opencode/DOCUMENTATION.md`
##### notifications
Notification message preparation utilities for system notifications, including text truncation and optional summarization.
- Module docs: `packages/web/server/lib/notifications/DOCUMENTATION.md`
##### terminal
WebSocket protocol utilities for terminal input handling including message normalization, control frame parsing, and rate limiting.
- Module docs: `packages/web/server/lib/terminal/DOCUMENTATION.md`
##### tts
Server-side text-to-speech services and summarization helpers for `/api/tts/*` endpoints.
- Module docs: `packages/web/server/lib/tts/DOCUMENTATION.md`
##### skills-catalog
Skills catalog management including discovery, installation, and configuration of agent skill packages.
- Module docs: `packages/web/server/lib/skills-catalog/DOCUMENTATION.md`
## Build / dev commands (verified)
All scripts are in `package.json`.
- Validate: `bun run type-check`, `bun run lint`
- Build all: `bun run build`
- Desktop build: `bun run desktop:build`
- Desktop build (Electron — primary): `bun run electron:build`
- Desktop dev (Electron): `bun run electron:dev`
- Desktop build (Tauri — legacy): `bun run desktop:build`
- VS Code build: `bun run vscode:build`
- Release smoke build: `bun run release:test` (shell script: `scripts/test-release-build.sh`)
## Runtime entry points
- Web bootstrap: `packages/web/src/main.tsx`
- Web server: `packages/web/server/index.js`
- Web CLI: `packages/web/bin/cli.js` (package bin: `packages/web/package.json`)
- Desktop: Tauri entry `packages/desktop/src-tauri/src/main.rs` (spawns web server sidecar + loads web UI)
- Tauri backend: `packages/desktop/src-tauri/src/main.rs`
- Desktop (Electron — primary): `packages/electron/main.mjs` (boots the web server in-process via `startWebUiServer`, loads web UI over loopback; preload at `packages/electron/preload.mjs` exposes the `__TAURI__` IPC shim so shared UI code is shell-agnostic)
- Desktop (Tauri — legacy): `packages/desktop/src-tauri/src/main.rs`
- VS Code extension host: `packages/vscode/src/extension.ts`
- VS Code webview bootstrap: `packages/vscode/webview/main.tsx`
## OpenCode integration
- UI client wrapper: `packages/ui/src/lib/opencode/client.ts` (imports `@opencode-ai/sdk/v2`)
- SSE hookup: `packages/ui/src/hooks/useEventStream.ts`
- Web server embeds/starts OpenCode server: `packages/web/server/index.js` (`createOpencodeServer`)
@@ -90,6 +132,7 @@ All scripts are in `package.json`.
- External server support: Set `OPENCODE_HOST` (full base URL, e.g. `http://hostname:4096`) or `OPENCODE_PORT`, plus `OPENCODE_SKIP_START=true`, to connect to existing OpenCode instance
## Key UI patterns (reference files)
- Settings shell: `packages/ui/src/components/views/SettingsView.tsx`
- Settings shared primitives: `packages/ui/src/components/sections/shared/`
- Settings sections: `packages/ui/src/components/sections/` (incl `skills/`)
@@ -98,27 +141,74 @@ All scripts are in `package.json`.
- Terminal UI: `packages/ui/src/components/terminal/` (uses `ghostty-web`)
## External / system integrations (active)
- Git: `packages/ui/src/lib/gitApi.ts`, `packages/web/server/index.js` (`simple-git`)
- Terminal PTY: `packages/web/server/index.js` (`bun-pty`/`node-pty`)
- Skills catalog: `packages/web/server/lib/skills-catalog/`, UI: `packages/ui/src/components/sections/skills/`
## Agent constraints
- Do not modify `../opencode` (separate repo).
- Do not run git/GitHub commands unless explicitly asked.
- Keep baseline green (run `bun run type-check`, `bun run lint`, `bun run build` before finalizing changes).
- Keep baseline green (run `bun run type-check`, `bun run lint` before finalizing changes).
## Agent code of conduct
- Prefer the smallest correct change.
- Preserve working behavior before improving structure.
- Do not add cleverness where a direct implementation is enough.
- Do not infer critical state from weak signals when a stronger source exists.
- Do not encode policy only in UI; enforce it in core logic.
- Do not hide data loss, partial failure, or fallback behavior. Make it explicit in code.
- Finish work end-to-end: implementation, verification, and cleanup.
## Development rules
- Keep diffs tight; avoid drive-by refactors.
- Backend changes: keep web/desktop/vscode runtimes consistent (if relevant).
- Follow local precedent; search nearby code first.
- TypeScript: avoid `any`/blind casts; keep ESLint/TS green.
- React: prefer function components + hooks; class only when needed (e.g. error boundaries).
- Control flow: avoid nested ternaries; prefer early returns + `if/else`/`switch`.
- Styling: Tailwind v4; typography via `packages/ui/src/lib/typography.ts`; theme vars via `packages/ui/src/lib/theme/`.
- Shared UI patterns: for "series of items + divider + series of items" layouts, use shared UI primitives instead of duplicating ad-hoc markup in feature components.
- Toasts: use custom toast wrapper from `@/components/ui` (backed by `packages/ui/src/components/ui/toast.ts`); do not import `sonner` directly in feature code.
- Follow local precedent; inspect nearby code before introducing new patterns.
- Backend changes: keep web, desktop, and VS Code behavior consistent when they share contracts.
- TypeScript: avoid `any`, blind casts, and shape guessing.
- React: prefer function components + hooks; use classes only when required.
- Control flow: prefer early returns and explicit branching over nested ternaries.
- Styling: Tailwind v4, typography via `packages/ui/src/lib/typography.ts`, theme vars via `packages/ui/src/lib/theme/`.
- Shared UI patterns: reuse shared primitives before introducing feature-local markup patterns.
- Toasts: use the wrapper from `@/components/ui`; do not import `sonner` directly in feature code.
- No new deps unless asked.
- Never add secrets (`.env`, keys) or log sensitive data.
- Never add secrets or log sensitive data.
## Architecture patterns
### Thin entrypoints, focused modules
- Keep orchestration entrypoints thin: `index.js`, bridge files, bootstrap files, provider roots.
- Move route, domain, and runtime logic into focused modules with clear ownership.
- Prefer dependency injection over hidden module coupling.
- Add or update module documentation when ownership changes.
### Strong source of truth
- Prefer deterministic state over heuristics.
- Use live server/session state for live activity. Do not let historical anomalies masquerade as current execution.
- If a fallback is necessary, scope it narrowly to the active entity and treat it as temporary.
- Restore derived UI state from authoritative records. Example: restore model or agent from the latest user message, not assistant-side guesses.
### Live state vs historical state
- Derive live UI behavior from live state channels, not persisted history.
- Use historical records to restore context, not to infer that work is still in progress.
- If live state is delayed, use the narrowest possible transient fallback and clear it as soon as authoritative state arrives.
### Cross-runtime parity
- If web defines a route or payload contract that shared UI depends on, keep VS Code and desktop parity where applicable.
- Shared behavior differences must be intentional and visible in code.
- Do not ship a web-only assumption into shared UI.
### Partial-failure-safe flows
- Cross-directory and multi-entity operations must tolerate partial failure.
- Prefer per-item results, rollback paths, or resumable cleanup over all-or-nothing assumptions.
- Never leave optimistic state or local caches stranded after failure.
## CLI Parity and Safety Policy (MANDATORY)
@@ -158,6 +248,7 @@ are defined in the `clack-cli-patterns` skill and should not be duplicated here.
When working on terminal CLI commands, prompts, or output formatting, agents **MUST** study the Clack CLI skill first.
**Before starting terminal CLI work:**
```
skill({ name: "clack-cli-patterns" })
```
@@ -169,12 +260,127 @@ Scope: terminal CLI only (for example `packages/web/bin/*`). Do not apply this r
When working on any UI components, styling, or visual changes, agents **MUST** study the theme system skill first.
**Before starting any UI work:**
```
skill({ name: "theme-system" })
```
This skill contains all color tokens, semantic logic, decision tree, and usage patterns. All UI colors must use theme tokens - never hardcoded values or Tailwind color classes.
## Performance rules (MANDATORY)
These rules exist because violating them has caused measurable regressions (render cascades, memory bloat, UI jank). They apply to all UI and sync layer work.
### Shared-store render discipline
- **Treat common stores as render fanout boundaries.** An unnecessary reference change in shared state can re-render large parts of the app.
- **Do not put high-frequency state in broadly consumed stores.** Fast-changing state should live in narrow stores with narrow subscribers.
- **Update only the fields that changed.** Preserve references for untouched state branches.
- **Prefer leaf selectors over container selectors.** Subscribe to the smallest stable value that satisfies the component.
- **Isolate hot consumers.** If a value changes often and only a few components need it, move it to a narrower store or consume it in a memoized child.
- **Do not subscribe shell/layout components to broad live collections.** If a shell only needs one field, entity, or derived flag, subscribe to that instead of the whole collection.
- **Treat provider roots as global hot paths.** A top-level provider must not subscribe to high-frequency data unless the feature is actually enabled and the subscription is essential.
### Zustand referential equality
Zustand skips re-renders when a selector returns the same reference (`Object.is`). Every new object/array reference triggers a re-render in every subscriber.
- **Never spread all state fields in an update.** Only create new references for fields that actually changed. A `message.part.delta` event should not clone `session`, `permission`, etc.
- **Select leaf values, not containers.** `useStore((s) => s.permission[sessionID])` is correct. `useStore((s) => s.permission)` subscribes to every permission change across all sessions.
- **Preserve references when merging.** If prepending older messages, keep existing message object references. Only add truly new items. Return the original array if nothing was added.
- **For derived collections, preserve item identity when presentation-relevant fields are unchanged.** Reuse previous item references for unchanged rows/items and move high-frequency live fields to narrow per-item selectors.
### Store splitting
A single store with N properties means every subscriber re-evaluates on every state change. Split stores by change frequency and subscriber set.
- **Group state by how often it changes.** Streaming state (updated 60/sec) must not live with user preferences (updated on click).
- **Group state by who reads it.** If only 2 components need a value, it belongs in a store that only those 2 subscribe to.
- **Cross-store reads use `.getState()`.** Actions in one store that need another store call `useOtherStore.getState()` — imperative, no subscription.
- **Never add unrelated state to an existing store** just because it's convenient. Create a new store.
### Event pipeline and SSE
- **Gate expensive operations on the hot path.** During streaming, `message.part.delta` and `message.part.updated` fire ~60/sec. Any `findIndex`, `filter`, or iteration added to these handlers multiplies across every event. Gate behind a cheap boolean check first (e.g., check `next[0]` before scanning the array).
- **Skip no-op updates.** If an incoming event doesn't change the state (same role, same finish, same timestamps), return `false` from the reducer to avoid creating new references.
- **Coalesce by key.** Same-entity events (e.g., repeated `session.status` for the same session) should replace earlier ones in the queue, not accumulate.
- **Preserve event ordering semantics.** Reducers and queues must not let stale deltas or out-of-order events corrupt the latest state.
- **Do not widen live-activity fallbacks.** A fallback for delayed status should inspect only the current trailing entity, not arbitrary historical records.
### Polling payload fidelity
- **Do not let lightweight polling erase rich fields.** If light mode omits fields (e.g., `diffStats`), preserve previous rich data until a heavy follow-up fetch lands.
- **Use two-phase polling.** Run cheap change detection first; only run heavy status fetches for directories that actually changed.
### Optimistic updates
- **Use the shadow Map pattern.** Insert optimistic data into the store for instant UI, AND register it in a separate tracking Map. Cleanup happens deterministically via `mergeOptimisticPage` on the next data fetch — not via heuristics in the event reducer.
- **Pass client-generated IDs to the server.** Use the same ID format as the server (hex-encoded timestamps). Pass `messageID` to `promptAsync` so the server echoes back the same ID. This prevents duplicates and enables in-place replacement.
- **Rollback on error.** Remove the optimistic entry from both the store and the shadow Map.
- **Stabilize bridge callbacks.** When wiring hook callbacks into module-level refs, use stable ref wrappers so effects do not loop on changing function identities.
### Session/input consistency
- **Capture send config at queue time.** Queue items must include provider/model/agent/variant snapshot; do not re-resolve from mutable live state at send time.
- **Keep server-selected attachments sendable.** Preserve server-backed file selections in queue/submit flows and convert them to proper `file://` URLs before sending.
- **Do not let text input state repaint unrelated chrome.** Typing should not force unrelated controls, menus, indicators, or toolbars to re-render on every keystroke.
- **Extract slow-changing chrome from hot input paths.** If controls do not depend on the current text value, move them behind memoized boundaries with stable callbacks.
### Bootstrap resilience
- **Treat startup 502/503 as transient.** Retry bootstrap/session-list flows with bounded retries/intervals, especially in VS Code where API readiness can lag bridge startup.
- **Use polling recovery when failures are swallowed.** If an async loader resolves without throwing on failure, recover with interval retries gated by loaded-state checks.
### Scroll and DOM
- **Never use `await waitForFrames()` for scroll preservation.** Frames of visible scroll jump are unacceptable. Use `useLayoutEffect` to adjust scroll synchronously after React commits DOM — before the browser paints.
- **Capture scroll state before the state change, restore in layout effect.** The pattern: save `scrollHeight`/`scrollTop` into a ref before triggering the update, consume it in `useLayoutEffect` on the rendered output.
- **Do not let viewport resizes masquerade as content growth.** Viewport-height changes must not trigger the same scroll compensation logic used for actual content growth.
- **Disable or narrow native/browser scroll anchoring when custom scroll logic exists.** Browser anchoring and app-managed pinning/follow logic will fight and produce jiggle.
- **Autosize textareas without transient collapse on growth.** Avoid `height='auto'` shrink/expand cycles on every character when the content only grew; this creates visible layout bounce.
### List ordering and view consistency
- **Do not sort structural lists directly from high-churn live fields.** If live updates are frequent, sorting directly from them causes reorder thrash and wide rerender cascades.
- **If live recency is required, freeze order during high-frequency updates and apply a one-shot reorder only at an intentional lifecycle edge.** Choose the lifecycle edge explicitly instead of letting every intermediate update reshuffle the UI.
- **Use one ordering source for all views of the same data.** Different views of the same entities must derive from the same ranked list or rank map; do not let each surface re-derive ordering independently.
- **Do not mix global snapshots and local live snapshots without an explicit reconciliation policy.** If multiple data sources feed one view, define which fields win and how they merge.
### Component isolation
- **Extract high-frequency hook consumers into separate components.** If a hook re-evaluates 60/sec (e.g., streaming status), wrap its consumer in a `React.memo` child component so the parent doesn't re-render.
- **Use custom `React.memo` comparators for message rows.** Compare render-relevant fields (role, finish, parts count, part IDs) — not object references.
### Caching and memory
- **Cap in-memory caches with both count and byte limits.** Entry count alone doesn't prevent memory bloat from large files. Use dual-constraint LRU (e.g., 40 entries OR 20MB).
- **Set store session limits to match loaded data.** If bootstrap loads N sessions, set `limit >= N`. Otherwise the next SSE event triggers trimming that silently removes sessions.
- **Invalidate caches on mutations.** File content cache must clear entries on write, delete, rename. Prefetch cache must clear on session eviction.
- **Use TTLs to prevent redundant fetches.** If a session was fetched <15s ago, skip re-fetching — SSE events keep it current.
### Directory context
- **Never cache directory strings in closures.** Directory can change at any time (worktree switch). Read it dynamically from `opencodeClient.getDirectory()` at call time.
- **Pass directory hints when the source of truth isn't available yet.** Newly created sessions aren't in the sync store until SSE delivers them. Pass the known directory as a parameter instead of relying on lookup.
## Regression-prevention checklist
- When adding fallback logic, ask: can stale persisted data keep this path active forever?
- When deriving UI state, ask: is this live state, historical state, or inferred state?
- When adding store fields, ask: who reads this, how often does it change, and should it live elsewhere?
- When touching polling or bootstrap, ask: can a lighter payload erase richer existing data?
- When handling optimistic updates, ask: where is rollback, reconciliation, and duplicate prevention?
- When changing shared routes or state contracts, ask: what breaks in web, desktop, and VS Code?
- When fixing a bug with a heuristic, prefer narrowing the heuristic over widening it.
## Validation expectations
- Run `bun run type-check` and `bun run lint` before finalizing.
- For hot-path changes, verify behavior under streaming or repeated events, not just static render.
- For sync or startup changes, verify fresh load, retry/failure, and restart behavior.
- For session changes, verify create, stream, abort, permission, archive/delete, and revisit flows when relevant.
## Recent changes
- Releases + high-level changes: `CHANGELOG.md`
- Recent commits: `git log --oneline` (latest tags: `v1.4.6`, `v1.4.5`)
+103 -83
View File
@@ -4,6 +4,106 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
## [1.9.9] - 2026-04-26
- UI/Localization: added a localization foundation with translated interface strings for Spanish, Brazilian Portuguese, Ukrainian, and Simplified Chinese.
- Settings/Appearance: added selectable interface and code fonts with 10 choices each.
- Chat/Workflow: added keyboard turn navigation, widened chat content, and introduced a local workspace review and summarize slash commands for faster review handoff.
- Chat/Mobile: improved mention and autocomplete behavior with complete results, clearer active-tab scoping, and less context-switching while drafting prompts.
- Chat/Tasks: todo list progress now updates live as task status changes, and task/model status hints are steadier during active runs (thanks to @Yabuku-xD).
- Files/Editor: added an "Open files in preview mode" setting and improved multi-file edit/diff safety so review flows stay cleaner (thanks to @daveotero).
- Reliability/Performance: improved cold start and streaming responsiveness with lazy-loaded heavy components, chunk-load recovery, lower re-render churn, and safer reconnect/local-stream recovery (thanks to @Yabuku-xD, @jwcrystal, @vhqtvn).
- Desktop/Web/Mobile: improved Electron update restart behavior, PWA service-worker notifications, mobile keyboard handling, and the Add Project panel flow (thanks to @Jovines, @vhqtvn).
## [1.9.8] - 2026-04-22
- Sessions/Reliability: fixed parent-child session sync during reconnects and navigation, so status and progress stay aligned in complex session trees (thanks to @jwcrystal).
- Settings/Sync: settings updates now sync more reliably across clients, and sidebar session pagination is steadier in larger workspaces.
- Sessions/Folders: folder changes now persist through server-backed endpoints, improving consistency across environments and path setups.
- Notifications: permission notifications are now suppressed when auto-accept is enabled, reducing noise during trusted runs.
- Chat/Files: improved changed-files handling in chat and restored quick file-open flows from pending changes, so jump-to-edit stays fast (thanks to @jwcrystal).
- UI: improved bottom scroll shadow behavior and hide the tasks row when there is no active work for a cleaner conversation view.
- Reliability/Desktop: improved live event-stream recovery after transient stalls, wait briefly before failing chat actions during reconnects, and persist Electron server logs for easier disconnect debugging.
- Desktop/macOS: System color mode now tracks OS theme changes, traffic-light controls stay visible after dock restore, and update restart/changelog handling is more reliable.
- Chat/Commands: added `/summary` slash command for a non-destructive session summary - optional topic hint after the command focuses the output, and the prompt is customizable under Settings: Magic Prompts.
## [1.9.7] - 2026-04-22
- Desktop: added an Electron desktop runtime in parallel with the current Tauri app, with Electron planned to become the default path in an upcoming release.
- Plans/Notes/Todos: added editable project plans from assistant messages, external plan upload, configurable planning magic prompts, and quicker note/todo handoff into new sessions or worktrees.
- Chat/Files: you can now drag files and folders from the file tree into chat, with improved `@folder` autocomplete for faster context building (thanks to @youfch).
- Sessions/UI: added bulk session selection in the sidebar and fixed pinned sessions so they persist reliably after reloads (thanks to @yart).
- Files/Git: added a file-change summary bar and auto-refresh for open files changed outside the app, improving review flow and keeping editors in sync (thanks to @jwcrystal).
- Git/Worktrees: improved branch/worktree reliability by allowing checkout with uncommitted changes, tightening worktree cache invalidation, and reducing incorrect remote prefetches (thanks to @jwcrystal, @jasonalsing).
- Settings/MCP: improved MCP auth flow with better remote-config support and clearer diagnostics, and aligned config resolution with OpenCode behavior for more predictable setup (thanks to @daveotero, @cyan).
- Reliability/Chat: hardened bootstrap and stream-connection recovery, preserved session/connect state more reliably, and reduced streaming UI churn for smoother long runs.
- Web/PWA: added install orientation controls and fixed loopback-origin handling for web push notifications in local setups (thanks to @vhqtvn, @yart).
## [1.9.6] - 2026-04-17
- Reliability/Streaming: switched live message events to a WebSocket-first transport with SSE fallback, added response compression, and hardened proxy/compression handling so long runs stay smoother on slower or proxied networks (thanks to @geekifan, @jwcrystal).
- Sessions/Scheduled Tasks: added scheduled task creation and management with locale-aware scheduling, so recurring prompts run at the right local time without manual re-entry.
- Sessions/Worktrees: enforced session worktree isolation and tightened session-switch safety, reducing cross-worktree mix-ups when resuming chats or running Git actions (thanks to @jwcrystal).
- Files: added a full Go to Line workflow (toolbar + shortcut + dialog) and a new Copy Relative Path action, making in-editor navigation and path sharing much faster (thanks to @coldbrow).
- Files: file trees now auto-refresh when files change outside the app, so new, renamed, or updated files appear without manual reloads (thanks to @jwcrystal).
- Chat/Export: added export session as Markdown and improved empty-state/export behavior, making conversation handoff and documentation cleaner (thanks to @coldbrow).
- Chat/Requests: restored blocking request visibility in sub-sessions, scoped auto-approve to the active session tree, and reduced noisy auto-approved notifications during multi-session work.
- Desktop: added quick open and a LAN access toggle, plus safer quit behavior around scheduled tasks for smoother local-network and day-to-day desktop workflows (thanks to @An-jinu).
- Chat/Markdown: added LaTeX rendering support for clearer math and technical notation in messages (thanks to @ricautomation).
- Settings/Skills: skills are now sorted within groups so larger skill lists are easier to scan (thanks to @roctom).
## [1.9.5] - 2026-04-14
- Security/Auth: added passkey sign-in for protected instances and new 1-week/30-day session expiration options, so teams can enforce stronger access controls with flexible login persistence (thanks to @daveotero, @pm0u).
- Voice: added OpenAI-compatible custom server support for both text-to-speech and speech-to-text, including configurable TTS model/pitch/volume and stricter custom URL validation for safer setup (thanks to @ablepharus).
- Chat/Tool Output: added an interactive tree viewer for structured outputs and fixed JSON quote rendering, making large payloads easier to inspect and copy accurately (thanks to @yaozhenghangma).
- Chat/Reliability: fixed question-tool content disappearing after refresh and hardened subagent/session recovery paths, reducing silent failures and stuck task states (thanks to @jwcrystal).
- Sync/Performance: optimized multi-session streaming with per-directory queues, event coalescing, and parts-gap recovery to keep live updates smoother under heavy activity (thanks to @jwcrystal).
- Sessions/UI: kept active sessions visible in Recent, auto-expanded parent groups when opening subagent sessions, and hid empty archived/folder sections for cleaner navigation (thanks to @jwcrystal).
- Git/UI: restored Git changes panel visibility and sidebar sync, so change review stays available and consistent while switching contexts (thanks to @jwcrystal).
- Desktop/Startup: delivered a more guided first-launch and smart recovery flow, plus startup and remote-window interaction fixes to reduce early-session friction (thanks to @jwcrystal).
- Usage: added Zhipu AI Coding Plan tracking and restored model-variant compatibility with older OpenCode runtimes for more reliable quota reporting and model selection (thanks to @cainiao1992, @Chi-square-test).
## [1.9.4] - 2026-04-07
- Settings/Magic Prompts: added a dedicated Magic Prompts page with editable templates for commit/PR generation, PR and issue reviews, failed-check/comment analysis, and merge/cherry-pick conflict resolution.
- Chat/Performance: reduced streaming render churn across the app, so long responses stay smoother with less UI jitter during active runs.
- Chat/Scrolling: fixed jumpy follow behavior and restored stable bottom-resume/live-compaction updates, so staying on the latest output is more reliable.
- Reliability/Streaming: improved reconnect, retry, and directory-aware event routing to reduce stuck session/subagent states after transient disconnects (thanks to @jwcrystal, @daveotero).
- Chat/Tool Output: LSP diagnostics now render directly in tool output, making inline error review faster while iterating (thanks to @yulia-ivashko).
- Models: added defensive handling for missing model pricing/capability metadata so model controls fail less often with incomplete provider data (thanks to @Chi-square-test).
- Desktop/Performance: removed costly window translucency and reduced duplicate notification triggers for a cooler, less noisy desktop experience.
- Startup/Remote: restored remote provider startup behavior and tightened host/port detection to reduce false startup failures.
- Usage: refreshed MiniMax CN coding-plan quota data for more accurate usage reporting (thanks to @nzlov).
## [1.9.3] - 2026-03-01
- Security/Chat: user messages now escape raw HTML by default, so pasted markup is shown safely as text instead of being interpreted by the renderer (thanks to @kalac2232).
- Desktop/Performance: reduced Tauri shell CPU/GPU overhead to keep the Desktop app cooler and smoother during longer sessions.
- Sessions/Drafts: draft chat config now stays synced with the selected draft target directory, reducing wrong-model or wrong-agent carryover when switching draft context (thanks to @hkay-dev).
- VSCode/Files: added file stat support in the extension bridge so markdown-related file checks resolve more reliably before opening or rendering (thanks to @geekifan).
- Chat/Models: added arrow-key navigation for thinking-mode selection in model controls, making keyboard model tuning faster during prompt setup (thanks to @daveotero).
- Files: added HTML preview support in the file viewer, so `.html` files can be inspected visually without leaving OpenChamber (thanks to @nguyenngothuong).
- Chat: improved error message readability with clearer styling and safer word-wrapping, so failures are easier to scan without layout breakage (thanks to @nguyenngothuong).
- Chat/JSON: added an interactive JSON tree viewer with collapse/expand controls and richer color cues for easier inspection of large structured outputs (thanks to @nguyenngothuong).
- Mobile/Settings: fixed lingering settings drawers and removed extra top spacing for a cleaner, less obstructed mobile layout (thanks to @Jovines).
- Git/Worktrees: fixed worktree detection and reset stale integration state when switching contexts, reducing wrong-target behavior in worktree flows (thanks to @jwcrystal).
- Desktop/Settings: window vibrancy now correctly controls macOS window transparency, and settings copy now clarifies when full transparency changes take effect.
- Reliability/Proxy: hardened OpenCode proxy header handling (including identity-encoding normalization, compression-header cleanup, hop-by-hop response-header stripping) and suppressed expected SSE close noise, improving stream stability and reducing false proxy errors (thanks to @jwcrystal, @Jovines, @JiwaniZakir, @shekohex).
- Reliability/Proxy: restored proxied chat event streaming so live responses continue working when OpenChamber is deployed behind a proxy.
- Terminal/Reliability: switched terminal transport to a pure WebSocket path with fallback handling, improving responsiveness and stability for interactive terminal sessions (thanks to @geekifan).
- Usage/Providers: added ZhipuAI quota tracking and fixed MiniMax coding-plan and GitHub Copilot overusage calculations for more accurate usage reporting (thanks to @kalac2232, @baruchvitorino, @ebrainte).
## [1.9.2] - 2026-03-31
- Chat/Performance: rebuilt live session sync and streaming updates to cut render churn, reduce CPU spikes, and keep long-running chats smoother and more stable across runtimes.
- Worktrees/Multi-Run: added instant draft-first worktree creation and redesigned the multi-run launcher with a cleaner, faster flow for parallel runs.
- VSCode/UI: polished the extension chat and sidebar with improved spacing/tooltips, a resizable sessions pane, and better file-to-chat mention flows from Explorer.
- Models/Providers: improved custom provider model metadata loading and caching so model details stay more complete and consistent (thanks to @ZeppLu).
- CLI/Server: added `--foreground` for process-manager deployments, made managed server hostname configurable, and added an explicit `--host` option with safer localhost defaults (thanks to @colinmollenhour, @rapidrabbit76, @yulia-ivashko).
- Docker/Deployments: improved container defaults for broader compatibility, including UID 1000 user behavior, non-fatal SSH key generation, and better localhost detection in container networking (thanks to @yulia-ivashko).
- Web/PWA: fixed manifest behavior behind Cloudflare Access so install flows work more reliably in protected environments (thanks to @arthurfiorette).
## [1.9.1] - 2026-03-20
- Sessions/UI: restored Project Notes access in the sidebar, polished notes/todo editing, and fixed project action overlap so project controls stay reachable for non-git directories.
@@ -16,7 +116,6 @@ All notable changes to this project will be documented in this file.
- Desktop: improved stale server-process cleanup on startup and fixed external link opening behavior for more predictable app interactions (thanks to @jwcrystal).
- Usage: added MiniMax Weekly quota provider support for broader usage tracking coverage (thanks to @nzlov).
## [1.9.0] - 2026-03-20
- UI/Navigation: delivered a major sidebar redesign with clearer hierarchy, unified action patterns, and improved session organization for better navigation through multiple projects (thanks to @yulia-ivashko).
@@ -33,7 +132,6 @@ All notable changes to this project will be documented in this file.
- Desktop/macOS: lowered the minimum supported macOS version to Ventura (13.0), expanding compatibility on older systems (thanks to @craigharman).
- Updates/Reliability: unified update-check behavior across runtimes for more consistent update availability checks.
## [1.8.7] - 2026-03-13
- CLI: fixed a startup regression in global npm/bun installs where wrapper or symlinked `openchamber` entrypoints could exit without output on commands like `--version` or `status`.
@@ -41,8 +139,6 @@ All notable changes to this project will be documented in this file.
- Windows/Web: daemon startup and Git operations no longer flash extra console windows, making background workflows less distracting (thanks to @SergioChan).
- Deployment/Docker: improved `docker run` startup behavior and entrypoint handling so containerized installs start more reliably (thanks to @nzlov).
## [1.8.6] - 2026-03-13
- Tunnel/CLI: rebuilt tunnel workflows around clearer managed modes and provider-aware lifecycle commands, with safer startup checks, improved diagnostics, and cleaner CLI output for everyday remote access (thanks to @yulia-ivashko).
@@ -68,7 +164,6 @@ All notable changes to this project will be documented in this file.
- Tunnel/CLI: fixed one-time Cloudflare tunnel connect links in CLI output for `--try-cf-tunnel`, so remote collaborators can use the printed URL/QR flow successfully (thanks to @plfavreau).
- Mobile/PWA: respected OS rotation lock by removing forced orientation behavior in the web app shell (thanks to @theluckystrike).
## [1.8.4] - 2026-03-04
- Chat: added clickable file-path links in assistant messages (including line targeting), so you can jump from answer text straight to the exact file location (thanks to @yulia-ivashko).
@@ -89,7 +184,6 @@ All notable changes to this project will be documented in this file.
- UI: interactive controls now consistently show pointer cursors, improving click affordance and reducing ambiguous hover states (thanks to @KJdotIO).
- Security/Reliability: hardened terminal auth, tightened skill-file path protections, and reduced sensitive request logging exposure for safer day-to-day usage (thanks to @yulia-ivashko).
## [1.8.3] - 2026-03-02
- Chat: added user-message display controls for plain-text rendering and sticky headers, so you can tune readability to match your preferences.
@@ -103,7 +197,6 @@ All notable changes to this project will be documented in this file.
- Settings: reorganized chat display settings into a more compact two-column layout, so more new options are easier to navigate.
- Mobile/UI: fixed session-title overflow in compact headers so running/unread indicators and actions remain visible (thanks to @iamhenry).
## [1.8.2] - 2026-03-01
- Updates: hardened the self-update flow with safer release handling and fallback behavior, reducing failed or stuck updates.
@@ -116,12 +209,10 @@ All notable changes to this project will be documented in this file.
- Notifications/Voice: consolidated TTS and summarization service wiring for steadier text-to-speech and summary flows (thanks to @nelsonPires5).
- Deployment: fixed Docker build/runtime issues for more reliable containerized setups (thanks to @nzlov).
## [1.8.1] - 2026-02-28
- Web/Auth: fixed an issue where non-tunnel browser sessions could incorrectly show a tunnel-only lock screen; normal auth flow now appears unless a tunnel is actually active.
## [1.8.0] - 2026-02-28
- Desktop: added SSH remote instance support with dedicated lifecycle and UX flows, so you can work against remote machines more reliably (thanks to @shekohex).
@@ -147,7 +238,6 @@ All notable changes to this project will be documented in this file.
- Usage: added MiniMax coding-plan quota provider support for broader usage tracking coverage (thanks to @nzlov).
- Usage: added Ollama Cloud quota provider support for broader usage tracking coverage (thanks to @iamhenry).
## [1.7.5] - 2026-02-25
- UI: moved projects into a dedicated sidebar rail and tightened the layout so switching projects and sessions feels faster.
@@ -159,7 +249,6 @@ All notable changes to this project will be documented in this file.
- Web: added `OPENCODE_HOST` support so you can connect directly to an external OpenCode server using a full base URL (thanks to @colinmollenhour).
- Web/Mobile: fixed in-app update flow in containerized setups so updates apply correctly.
## [1.7.4] - 2026-02-24
- Settings: redesigned the settings workspace with flatter, more consistent page layouts so configuration is faster to scan and edit.
@@ -176,7 +265,6 @@ All notable changes to this project will be documented in this file.
- Desktop: improved remote instance URL handling for more reliable host/query matching (thanks to @shekohex).
- Files: added C, C++, and Go language support for syntax-aware rendering in code-heavy workflows (thanks to @fomenks).
## [1.7.3] - 2026-02-21
- Settings: added customizable keyboard shortcuts for chat actions, panel toggles, and services, so you can better match OpenChamber to your workflow (thanks to @nelsonPires5).
@@ -188,7 +276,6 @@ All notable changes to this project will be documented in this file.
- Reliability: improved startup environment detection by capturing login-shell environment snapshots, reducing missing PATH/tool issues on launch.
- Reliability: refactored OpenCode config/auth integration into domain modules for steadier provider auth and command loading flows (thanks to @nelsonPires5).
## [1.7.2] - 2026-02-20
- Chat: question prompts now guide you to unanswered items before submit, making tool-question flows faster.
@@ -198,7 +285,6 @@ All notable changes to this project will be documented in this file.
- Settings: model variant options now refresh correctly in draft/new-session flows, avoiding stale selections.
- Reliability: provider auth failures now show clearer re-auth guidance when tokens expire, making recovery faster (thanks to @yulia-ivashko).
## [1.7.1] - 2026-02-18
- Chat: slash commands now follow server command semantics (including multiline arguments), so command behavior is more consistent with OpenCode CLI.
@@ -210,17 +296,15 @@ All notable changes to this project will be documented in this file.
- Mobile: fixed accidental abort right after tapping Send on touch devices, reducing interrupted responses (thanks to @shekohex).
- Maintenance: removed deprecated GitHub Actions cloud runtime assets and docs to reduce setup confusion (thanks to @yulia-ivashko).
## [1.7.0] - 2026-02-17
- Chat: improved live streaming with part-delta updates and smarter auto-follow scrolling, so long responses stay readable while they generate.
- Chat: Mermaid diagrams now render inline in assistant messages, with quick copy/download actions for easier sharing.
- UI: added a context overview panel with token usage, cost breakdown, and raw message inspection to make session debugging easier.
- Sessions: project icon and color customizations now persist reliably across restarts.
**- Reliability: managed local OpenCode runtimes now use rotated secure auth and tighter lifecycle control across runtimes, reducing stale-process and reconnect issues (thanks to @yulia-ivashko).**
**- Reliability: managed local OpenCode runtimes now use rotated secure auth and tighter lifecycle control across runtimes, reducing stale-process and reconnect issues (thanks to @yulia-ivashko).**
- Git/GitHub: improved backend reliability for repository and auth operations, helping branch and PR flows stay more predictable (thanks to @nelsonPires5).
## [1.6.9] - 2026-02-16
- **UI: redesigned the workspace shell with a context panel, tabbed sidebars, and quicker navigation across chat, files, and reviews, so daily workflows feel more focused.**
@@ -237,7 +321,6 @@ All notable changes to this project will be documented in this file.
- Desktop: improved day-to-day polish with restored desktop window geometry and posiotion (thanks to @yulia-ivashko).
- Mobile: fixes for small-screen editor, terminal, and layout overlap issues (thanks to @gsxdsm, @nelsonPires5).
## [1.6.8] - 2026-02-12
- Chat: added drag-and-drop attachments with inline image previews, so sharing screenshots and files in prompts feels much faster and more reliable.
@@ -249,7 +332,6 @@ All notable changes to this project will be documented in this file.
- Desktop: fixed project selection in opened remote instances.
- Desktop: fixed opened remote instances that use HTTP (helpful for instances under tunneling).
## [1.6.7] - 2026-02-10
- Voice: added built-in voice input and read-aloud responses with multiple providers, so you can drive chats hands-free when typing is slower (thanks to @gsxdsm).
@@ -277,7 +359,6 @@ All notable changes to this project will be documented in this file.
- Mobile: fixed chat input layout issues on small screens (thanks to @nelsonPires5).
- Reliability: fixed OpenCode auth pass-through and proxy env handling to reduce intermittent connection/auth issues (thanks to @gsxdsm).
## [1.6.5] - 2026-02-6
- Settings: added an OpenCode CLI path override so you can point OpenChamber at a custom/local CLI install.
@@ -290,7 +371,6 @@ All notable changes to this project will be documented in this file.
- UI: added Vitesse Dark and Vitesse Light theme presets.
- Reliability: improved OpenCode binary resolution and HOME-path handling across runtimes for steadier local startup.
## [1.6.4] - 2026-02-5
- Desktop: switch between local and remote OpenChamber instances, plus a thinner runtime for better feature parity and fewer desktop-only quirks.
@@ -304,7 +384,6 @@ All notable changes to this project will be documented in this file.
- Web: fixed missing icon when installing the Android PWA (thanks to @nelsonPires5).
- GitHub: PR description generation supports optional extra context for better summaries (thanks to @nelsonPires5).
## [1.6.3] - 2026-02-2
- Web: improved server readiness check to use the `/global/health` endpoint for more reliable startup detection.
@@ -312,7 +391,6 @@ All notable changes to this project will be documented in this file.
- VSCode: improved server health check with the proper health API endpoint and increased timeout for steadier startup (thanks to @wienans).
- Settings: dialog no longer persists open/closed state across app restarts.
## [1.6.2] - 2026-02-1
- Usage: new multi-provider quota dashboard to monitor API usage across OpenAI, Google, and z.ai (thanks to @nelsonPires5).
@@ -324,7 +402,6 @@ All notable changes to this project will be documented in this file.
- Worktrees: workspace path now resolves correctly when using git worktrees (thanks to @nelsonPires5).
- Projects: fixed directory creation outside workspace in the Add Project modal (thanks to @nelsonPires5).
## [1.6.1] - 2026-01-30
- Chat: added Stop button to cancel generation mid-response.
@@ -337,7 +414,6 @@ All notable changes to this project will be documented in this file.
- Git: commit message generation now includes untracked files and handles git diff --no-index comparisons more reliably (thanks to @MrLYC).
- Desktop: improved macOS window chrome and header spacing, including steadier traffic lights on older macOS versions (thanks to @yulia-ivashko).
## [1.6.0] - 2026-01-29
- Chat: added message stall detection with automatic soft resync for more reliable message delivery.
@@ -349,7 +425,6 @@ All notable changes to this project will be documented in this file.
- Web: session activity tracking now works consistently across browser tabs.
- Reliability: plans directory no longer errors when missing.
## [1.5.9] - 2026-01-28
- Worktrees: migrated to Opencode SDK worktree implementation; sessions in worktrees are now completely isolated.
@@ -360,7 +435,6 @@ All notable changes to this project will be documented in this file.
- UI: Files, Diff, Git, and Terminal now follow the active session/worktree directory, including new-session drafts.
- Web: plan lists no longer error when the plans directory is missing.
## [1.5.8] - 2026-01-26
- Plans: new Plan/Build mode switching support with dedicated Plan content view with per-session context.
@@ -373,14 +447,12 @@ All notable changes to this project will be documented in this file.
- Activity: added a text-justification setting for activity summaries (thanks to @iyangdianfeng).
- Reliability: file lists and message sends handle missing directories and transient errors more gracefully.
## [1.5.7] - 2026-01-24
- GitHub: PR panel supports fork PR detection by branch name.
- GitHub: Git tab PR panel can send failed checks/comments to chat with hidden context; added check details dialog with Actions step breakdown.
- Web: GitHub auth flow fixes.
## [1.5.6] - 2026-01-24
- GitHub: connect your account in Settings with device-flow auth to enable GitHub tools.
@@ -389,7 +461,6 @@ All notable changes to this project will be documented in this file.
- Git: manage pull requests in the Git view with AI-generated descriptions, status checks, ready-for-review, and merge actions.
- Mobile: fixed CommandAutocomplete dropdown scrolling (thanks to @nelsonPires5).
## [1.5.5] - 2026-01-23
- Navigation: URLs now sync the active session, tab, settings, and diff state for shareable links and reliable back/forward (thanks to @TaylorBeeston).
@@ -398,7 +469,6 @@ All notable changes to this project will be documented in this file.
- Web: push notifications no longer fire when a window is visible, avoiding duplicate alerts.
- Web: improved push subscription handling across multiple windows for more reliable delivery.
## [1.5.4] - 2026-01-22
- Chat: new Apply Patch tool UI with diff preview for patch-based edits.
@@ -409,7 +479,6 @@ All notable changes to this project will be documented in this file.
- Web: added Background notifications for PWA.
- Reliability: connect to external OpenCode servers without auto-start and fixed subagent crashes (thanks to @TaylorBeeston).
## [1.5.3] - 2026-01-20
- Files: edit files inline with syntax highlighting, draft protection, and save/discard flow.
@@ -422,7 +491,6 @@ All notable changes to this project will be documented in this file.
- Git: generated commit messages now auto-pick a gitmoji when enabled (thanks to @TheRealAshik).
- Performance: faster filesystem/search operations and general stability improvements (thanks to @TheRealAshik).
## [1.5.2] - 2026-01-17
- Sessions: added branch picker dialog to start new worktree sessions from local branches (thanks to @nilskroe).
@@ -434,13 +502,11 @@ All notable changes to this project will be documented in this file.
- VSCode: tuned layout breakpoint and server readiness timeout for steadier startup.
- Reliability: improved OpenCode process cleanup to reduce orphaned servers.
## [1.5.1] - 2026-01-16
- Desktop: fixed orphaned OpenCode processes not being cleaned up on restart or exit.
- Opencode: fixed issue with reloading configuration was killing the app
## [1.5.0] - 2026-01-16
- UI: added a new Files tab to browse workspace files directly from the interface.
@@ -453,7 +519,6 @@ All notable changes to this project will be documented in this file.
- Stability: fixed heartbeat race condition causing session stalls during long tasks (thanks to @tybradle).
- Desktop: fixed commands for worktree setup access to PATH.
## [1.4.9] - 2026-01-14
- VSCode: added session editor panel to view sessions alongside files.
@@ -462,7 +527,6 @@ All notable changes to this project will be documented in this file.
- Mobile: fixed iOS keyboard safe area padding for home indicator bar (thanks to @Jovines).
- Upload: increased attachment size limit to 50MB with automatic image compression to 2048px for large files.
## [1.4.8] - 2026-01-14
- Git Identities: added token-based authentication support with ~/.git-credentials discovery and import.
@@ -474,13 +538,11 @@ All notable changes to this project will be documented in this file.
- Reliability: improved project state preservation on validation failures (thanks to @vio1ator) and refined server health monitoring.
- Stability: added graceful shutdown handling for the server process (thanks to @vio1ator).
## [1.4.7] - 2026-01-10
- Skills: added ClawdHub integration as built-in market for skills.
- Web: fixed issues in terminal
## [1.4.6] - 2026-01-09
- VSCode/Web: switch opencode cli management to SDK.
@@ -488,7 +550,6 @@ All notable changes to this project will be documented in this file.
- Shortcuts: switched agent cycling shortcut from Shift + TAB to TAB again.
- Chat: added question tool support with a rich UI for interaction.
## [1.4.5] - 2026-01-08
- Chat: added support for model variants (thinking effort).
@@ -499,7 +560,6 @@ All notable changes to this project will be documented in this file.
- MCP: added ability to dynamically enabled/disabled configured MCP.
- Web: refactored project adding UI with autocomplete.
## [1.4.4] - 2026-01-08
- Agent Manager / Multi Run: select agent per worktree session (thanks to @wienans).
@@ -515,7 +575,6 @@ All notable changes to this project will be documented in this file.
- Tunnel: added QR code and password URL for Cloudflare tunnel (thanks to @martindonadieu).
- Model selector: fixed dropdowns not responding to viewport size.
## [1.4.3] - 2026-01-04
- VS Code extension: added Agent Manager panel to run the same prompt across up to 5 models in parallel (thanks to @wienans).
@@ -523,7 +582,6 @@ All notable changes to this project will be documented in this file.
- Added "Open subAgent session" button on task tool outputs to quickly navigate to child sessions (thanks to @aptdnfapt).
- VS Code extension: improved activation reliability and error handling.
## [1.4.2] - 2026-01-02
- Added timeline dialog (`/timeline` command or Cmd/Ctrl+T) for navigating, reverting, and forking from any point in the conversation (thanks to @aptdnfapt).
@@ -532,7 +590,6 @@ All notable changes to this project will be documented in this file.
- Desktop app: keyboard shortcuts now use Cmd on macOS and Ctrl on web/other platforms (thanks to @sakhnyuk).
- Migrated to OpenCode SDK v2 with improved API types and streaming.
## [1.4.1] - 2026-01-02
- Added the ability to select the same model multiple times in multi-agent runs for response comparison.
@@ -545,7 +602,6 @@ All notable changes to this project will be documented in this file.
- Terminal: improved terminal performance and stability by switching to the Ghostty-based terminal renderer, while keeping the existing terminal UX and per-directory sessions.
- Terminal: fixed several issues with terminal session restore and rendering under heavy output, including switching directories and long-running TUI apps.
## [1.4.0] - 2026-01-01
- Added the ability to run multiple agents from a single prompt, with each agent working in an isolated worktree.
@@ -557,14 +613,12 @@ All notable changes to this project will be documented in this file.
- Chat: now shows clearer error messages when agent messages fail.
- Sidebar: improved readability for sticky headers with a dynamic background.
## [1.3.9] - 2025-12-30
- Added skills management to settings with the ability to create, edit, and delete skills (make sure you have the latest OpenCode version for skills support).
- Added skills management to settings with the ability to create, edit, and delete skills (make sure you have the latest OpenCode version for skills support).
- Added Skills catalog functionality for discovering and installing skills from external sources.
- VS Code extension: added right-click context menu with "Add to Context," "Explain," and "Improve Code" actions (thanks to @wienans).
## [1.3.8] - 2025-12-29
- Added Intel Mac (x86_64) support for the desktop application (thanks to @rothnic).
@@ -575,7 +629,6 @@ All notable changes to this project will be documented in this file.
- Fixed scroll position persistence for active conversation turns across session switches.
- Refactored Agents/Commands management with ability to configure project/user scopes.
## [1.3.7] - 2025-12-28
- Redesigned Settings as a full-screen view with tabbed navigation.
@@ -585,13 +638,11 @@ All notable changes to this project will be documented in this file.
- Improved session activity status handling and message step completion logic.
- Introduced enchanced VSCode extension settings with dynamic layout based on width.
## [1.3.6] - 2025-12-27
- Added the ability to manage (connect/disconnect) providers in settings.
- Adjusted auto-summarization visuals in chat.
## [1.3.5] - 2025-12-26
- Added Nushell support for operations with Opencode CLI.
@@ -603,14 +654,12 @@ All notable changes to this project will be documented in this file.
- Added Discord links in the about section.
- Added settings for choosing the default model/agent to start with in a new session.
## [1.3.4] - 2025-12-25
- Diff view now loads reliably even with large files and slow networks.
- Fixed getting diffs for worktree files.
- VS Code extension: improved type checking and editor integration.
## [1.3.3] - 2025-12-25
- Updated OpenCode SDK to 1.0.185 across all app versions.
@@ -621,13 +670,11 @@ All notable changes to this project will be documented in this file.
- Chat UI: improved turn grouping/activity rendering and fixed message metadata/agent selection propagation.
- Chat UI: improved agent activity status behavior and reduced image thumbnail sizes for better readability.
## [1.3.2] - 2025-12-22
- Fixed new bug session when switching directories
- Updated Opencode SDK to the latest version
## [1.3.1] - 2025-12-22
- New chats no longer create a session until you send your first message.
@@ -635,7 +682,6 @@ All notable changes to this project will be documented in this file.
- Fixed mobile and VSCode sessions handling
- Updated app identity with new logo and icons across all platforms.
## [1.3.0] - 2025-12-21
- Added revert functionality in chat for user messages.
@@ -646,21 +692,18 @@ All notable changes to this project will be documented in this file.
- Adjusted VSCode extension theme mapping and model selection view.
- Polished file autocomplete experience.
## [1.2.9] - 2025-12-20
- Session autocleanup feature with configurable retention for each app version including VSCode extension.
- Ability to update web package from mobile/PWA view in setting.
- A lot of different optimization for a long sessions.
## [1.2.8] - 2025-12-19
- Introduced update mechanism for web version that doesn't need any cli interaction.
- Added installation script for web version with package managed detection.
- Update and restart of web server now support automatic pick-up of previously set parameters like port or password.
## [1.2.7] - 2025-12-19
- Comprehensive macOS native menu bar entries.
@@ -668,14 +711,12 @@ All notable changes to this project will be documented in this file.
- Improved theme consistency across dropdown menus, selects, and command palette.
- Introduced keyboard shortcuts help menu and quick actions menu.
## [1.2.6] - 2025-12-19
- Added write/create tool preview in permission cards with syntax highlighting.
- More descriptive assistant status messages with tool-specific and varied idle phrases.
- Polished Git view layout
## [1.2.5] - 2025-12-19
- Polished chat expirience for longer session.
@@ -685,13 +726,11 @@ All notable changes to this project will be documented in this file.
- Fixed untracked files in new directories not showing individually.
- Smoother session rename experience.
## [1.2.4] - 2025-12-18
- MacOS app menu entries for Check for update and for creating bug/request in Help section.
- For Mobile added settings, improved terminal scrolling, fixed app layout positioning.
## [1.2.3] - 2025-12-17
- Added image preview support in Diff tab (shows original/modified images instead of base64 code).
@@ -699,28 +738,24 @@ All notable changes to this project will be documented in this file.
- Optimized git polling and background diff+syntax pre-warm for instant Diff tab open.
- Optomized reloading unaffected diffs.
## [1.2.2] - 2025-12-17
- Agent Task tool now renders progressively with live duration and completed sub-tools summary.
- Unified markdown rendering between assistant messages and tool outputs.
- Reduced markdown header sizes for better visual balance.
## [1.2.1] - 2025-12-16
- Todo task tracking: collapsible status row showing AI's current task and progress.
- Switched "Detailed" tool output mode to only open the 'task', 'edit', 'multiedit', 'write', 'bash' tools for better performance.
## [1.2.0] - 2025-12-15
- Favorite & recent models for quick access in model selection.
- Tool call expansion settings: collapsed, activity, or detailed modes.
- Font size & spacing controls (50-200% scaling) in Appearance Settings.
- Settings page access within VSCode extension.
Thanks to @theblazehen for contributing these features!
Thanks to @theblazehen for contributing these features!
## [1.1.6] - 2025-12-15
@@ -728,27 +763,23 @@ Thanks to @theblazehen for contributing these features!
- Improved mobile experience: simplified header, better diff file selector.
- Redesigned password-protected session unlock screen.
## [1.1.5] - 2025-12-15
- Enhanced file attachment features performance.
- Added fuzzy search feature for file mentioning with @ in chat.
- Optimized input area layout.
## [1.1.4] - 2025-12-15
- Flexoki themes for Shiki syntax highlighting for consistency with the app color schema.
- Enchanced VSCode extension theming with editor themes.
- Fixed mobile view model/agent selection.
## [1.1.3] - 2025-12-14
- Replaced Monaco diff editor with Pierre/diffs for better performance.
- Added line wrap toggle in diff view with dynamic layout switching (auto-inline when narrow).
## [1.1.2] - 2025-12-13
- Moved VS Code extension to activity bar (left sidebar).
@@ -756,13 +787,11 @@ Thanks to @theblazehen for contributing these features!
- Removed redundant VS Code commands.
- Enhanced UserTextPart styling.
## [1.1.1] - 2025-12-13
- Adjusted model/agent selection alignment.
- Fixed user message rendering issues.
## [1.1.0] - 2025-12-13
- Added assistant answer fork flow so users can start a new session from an assistant plan/response with inherited context.
@@ -770,7 +799,6 @@ Thanks to @theblazehen for contributing these features!
- Improved scroll performance with force flag and RAF placeholder.
- Added git polling backoff optimization.
## [1.0.9] - 2025-12-08
- Added directory picker on first launch to reduce macOS permission prompts.
@@ -778,48 +806,40 @@ Thanks to @theblazehen for contributing these features!
- Improved update dialog UI with inline version display.
- Added macOS folder access usage descriptions.
## [1.0.8] - 2025-12-08
- Added fallback detection for OpenCode CLI in ~/.opencode/bin.
- Added window focus after app restart/update.
- Adapted traffic lights position and corner radius for older macOS versions.
## [1.0.7] - 2025-12-08
- Optimized Opencode binary detection.
- Adjusted app update experience.
## [1.0.6] - 2025-12-08
- Enhance shell environment detection.
## [1.0.5] - 2025-12-07
- Fixed "Load older messages" incorrectly scrolling to bottom.
- Fixed page refresh getting stuck on splash screen.
- Disabled devtools and page refresh in production builds.
## [1.0.4] - 2025-12-07
- Optimized desktop app start time
## [1.0.3] - 2025-12-07
- Updated onboarding UI.
- Updated sidebar styles.
## [1.0.2] - 2025-12-07
- Updated MacOS window design to the latest one.
## [1.0.1] - 2025-12-07
- Initial public release of OpenChamber web and desktop packages in a unified monorepo.
+5
View File
@@ -241,6 +241,11 @@ environment:
Managed-local path note: `OPENCHAMBER_TUNNEL_CONFIG` must point to a path inside the container user home (`/home/openchamber/...`). If your Cloudflare config references a credentials JSON file, that file path must also be accessible inside the container (mount with `volumes`).
### Reverse proxy notes
- For a complete reverse proxy setup guide, see [`docs/REVERSE_PROXY.md`](./docs/REVERSE_PROXY.md).
- Website docs source lives at `packages/docs/content/docs/reverse-proxy.mdx`.
### Tunnel behavior notes
- OpenChamber supports one active tunnel per running instance (port).
+891 -85
View File
File diff suppressed because it is too large Load Diff
+337
View File
@@ -0,0 +1,337 @@
# Reverse Proxy Setup
Use this guide when running OpenChamber behind Nginx, Nginx Proxy Manager, Caddy, Cloudflare, or another reverse proxy.
## Before you proxy it
1. Confirm OpenChamber works directly first.
2. Open `http://<server-ip>:3000` or your custom port from the same network.
3. Only add the reverse proxy after the direct connection works.
## What the proxy must support
- WebSockets for live message transport:
- `/api/event/ws`
- `/api/global/event/ws`
- `/api/terminal/ws`
- SSE without buffering:
- `/api/event`
- `/api/global/event`
- `/api/notifications/stream`
- `/api/openchamber/events`
- `/api/terminal/:sessionId/stream`
- Large request bodies for attachments and file operations
- Long-lived read timeouts for live streams and terminal sessions
## Rules that matter
- Enable WebSocket proxying.
- Disable buffering on SSE routes.
- Disable gzip on the proxy if OpenChamber is already compressing responses.
- Keep compression enabled in only one layer.
- Forward normal proxy headers such as `Host`, `X-Forwarded-For`, and `X-Forwarded-Proto`.
- Increase body size limits if users upload files.
## Quick checklist
- OpenChamber reachable directly on LAN
- WebSockets enabled in the proxy
- SSE routes have buffering off
- `gzip off` on the proxy host, or proxy compression disabled another way
- `client_max_body_size` large enough for attachments
- `proxy_read_timeout` long enough for streams
## Example: Nginx
<details>
<summary>Show example config</summary>
```nginx
client_max_body_size 50M;
client_body_buffer_size 50M;
proxy_request_buffering off;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
gzip off;
location = /api/terminal/ws {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location = /api/global/event/ws {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location = /api/event/ws {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location ~ ^/api/(event|global/event|notifications/stream|openchamber/events)$ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Accept "text/event-stream";
proxy_set_header Cache-Control "no-cache";
proxy_buffering off;
proxy_cache off;
gzip off;
add_header X-Accel-Buffering "no" always;
add_header Cache-Control "no-cache, no-transform" always;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location ~ ^/api/terminal/.+/stream$ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Accept "text/event-stream";
proxy_set_header Cache-Control "no-cache";
proxy_buffering off;
proxy_cache off;
gzip off;
add_header X-Accel-Buffering "no" always;
add_header Cache-Control "no-cache, no-transform" always;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location /api {
proxy_pass http://127.0.0.1:3000;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location / {
proxy_pass http://127.0.0.1:3000;
}
```
</details>
## Example: Nginx Proxy Manager
<details>
<summary>Show Advanced tab example</summary>
```nginx
client_max_body_size 50M;
client_body_buffer_size 50M;
proxy_request_buffering off;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
gzip off;
location = /api/terminal/ws {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 30s;
}
location = /api/global/event/ws {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 30s;
}
location = /api/event/ws {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 30s;
}
location = /api/event {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Accept "text/event-stream";
proxy_set_header Cache-Control "no-cache";
proxy_buffering off;
proxy_cache off;
gzip off;
add_header X-Accel-Buffering "no" always;
add_header Cache-Control "no-cache, no-transform" always;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 30s;
}
location = /api/global/event {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Accept "text/event-stream";
proxy_set_header Cache-Control "no-cache";
proxy_buffering off;
proxy_cache off;
gzip off;
add_header X-Accel-Buffering "no" always;
add_header Cache-Control "no-cache, no-transform" always;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 30s;
}
location = /api/notifications/stream {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Accept "text/event-stream";
proxy_set_header Cache-Control "no-cache";
proxy_buffering off;
proxy_cache off;
gzip off;
add_header X-Accel-Buffering "no" always;
add_header Cache-Control "no-cache, no-transform" always;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 30s;
}
location = /api/openchamber/events {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Accept "text/event-stream";
proxy_set_header Cache-Control "no-cache";
proxy_buffering off;
proxy_cache off;
gzip off;
add_header X-Accel-Buffering "no" always;
add_header Cache-Control "no-cache, no-transform" always;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 30s;
}
location ~ ^/api/terminal/.+/stream$ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Accept "text/event-stream";
proxy_set_header Cache-Control "no-cache";
proxy_buffering off;
proxy_cache off;
gzip off;
add_header X-Accel-Buffering "no" always;
add_header Cache-Control "no-cache, no-transform" always;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 30s;
}
location /api {
proxy_pass http://127.0.0.1:3000;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 30s;
}
location / {
proxy_pass http://127.0.0.1:3000;
}
```
</details>
Also enable `Websockets Support` in Nginx Proxy Manager for this host.
## Common failure signs
### Page loads, but sending messages fails
- WebSockets are not enabled in the proxy
- `/api/event/ws` or `/api/global/event/ws` is not passing through correctly
### Notifications or live status do not update
- one of the SSE routes is buffered or cached
- `X-Accel-Buffering "no"` is missing
### File uploads fail
- `client_max_body_size` is too small
### Everything works locally, but breaks only behind the proxy
- the proxy is compressing and buffering live traffic
- the proxy is missing WebSocket support
## Example: Caddy
<details>
<summary>Show example config</summary>
```caddy
reverse_proxy 127.0.0.1:3000 {
# WebSocket support is automatic in Caddy
# Flush SSE responses immediately
flush_interval -1
# Pass through Host and proxy headers
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
# Increase timeouts for long-lived streams
transport http {
read_timeout 3600s
write_timeout 3600s
}
}
```
</details>
Caddy handles WebSocket upgrades automatically — no extra configuration needed. The `flush_interval -1` directive ensures SSE chunks are forwarded immediately without buffering.
## CDN and double-compression warning
If you place a CDN (such as Cloudflare) in front of your reverse proxy, be aware of double compression:
- OpenChamber compresses HTTP responses with gzip (threshold 1 KB).
- Cloudflare and other CDNs also compress responses by default.
- This can cause double-compressed responses or incorrect `Content-Encoding` headers.
To avoid this, disable compression at **one** layer:
- **Cloudflare:** Rules → Compression → disable (or use "Passthrough" mode).
- **Nginx:** `gzip off` (already shown in the examples above).
- **Caddy:** Caddy does not re-compress by default if the upstream already sends compressed content.
SSE streaming routes are excluded from compression by OpenChamber, but the CDN may still buffer them. Check your CDN documentation for how to disable buffering on SSE paths.
+349
View File
@@ -0,0 +1,349 @@
# Tauri → Electron auto-update cutover
> Self-contained playbook. The branch and conversation where this plan was
> designed will not be around when the cutover happens — read this file top to
> bottom and execute; do not assume prior context.
## What this is
OpenChamber historically shipped as a Tauri app. A parallel Electron shell was
added on branch `electron-app` (merged to `main` as part of a larger migration).
Since then, both desktop shells have been released in the same GitHub release
and each has its own auto-update channel:
| Shell | Manifest | Update format | Secret used to sign |
|----------|-------------------|---------------------|---------------------|
| Tauri | `latest.json` | `.tar.gz` + `.sig` | `TAURI_SIGNING_PRIVATE_KEY` (minisign) |
| Electron | `latest-mac.yml` | `.zip` + `blockmap` | Developer ID codesign (APPLE_* secrets) |
Existing Tauri installs keep their own auto-update path (`latest.json`).
Electron installs auto-update through `latest-mac.yml`. They coexist without
conflict because filenames and manifests differ.
At some point the user wants to **stop maintaining the Tauri build** and make
the Tauri installs migrate themselves into Electron via auto-update. This
document describes how to do that in a single "transition release".
## The core trick
Tauri's updater downloads whatever `.tar.gz` the `latest.json` points at,
verifies the minisign signature, unpacks the contents **over** the existing
`.app` directory, and restarts. It does **not** introspect the payload — it
just replaces files.
So: produce a `.tar.gz` of the Electron `.app`, sign it with the existing
Tauri minisign key, point `latest.json` at it. Tauri users receive the update,
their `OpenChamber.app` becomes the Electron bundle in-place, and next launch
starts Electron. Subsequent updates go through `latest-mac.yml`
(electron-updater). One-way migration, one-shot workflow change.
## Prerequisites before running the cutover
Check all of these before making any release:
1. **Electron has shipped stable through its own `latest-mac.yml` path for at
least 2 releases.** Verify:
```
gh release list --repo btriapitsyn/openchamber
gh release view vX.Y.Z --repo btriapitsyn/openchamber \
| grep -E 'OpenChamber-.*\.zip|latest-mac\.yml'
```
A user on Electron should have successfully auto-updated at least once.
If not, pause and stabilise that path first — don't stack risk.
2. **`~/.config/openchamber/settings.json` is still the shared state path.**
Tauri `src-tauri/src/main.rs:settings_file_path` and Electron
`packages/electron/main.mjs:settingsFilePath` must both resolve to
`$HOME/.config/openchamber/settings.json`. If either has moved, data parity
breaks and this migration loses user data. Audit both paths, update the
non-migrated shell to match before proceeding.
3. **Electron `appId` is `dev.openchamber.desktop`** (check
`packages/electron/package.json` `build.appId`). Tauri identifier is
`ai.opencode.openchamber`. These differ intentionally — it means macOS
LaunchServices will re-register after the in-place replace. That's fine but
see "Risks" below.
4. **All GitHub secrets still valid:** `APPLE_CERTIFICATE`,
`APPLE_CERTIFICATE_PASSWORD`, `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID`,
`TAURI_SIGNING_PRIVATE_KEY`, `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. A
workflow_dispatch dry-run should succeed before the real tag.
5. **`minisign` CLI is available on the macOS runner** (or installable via
brew). Used to sign the Electron tarball with the Tauri key.
## Release workflow changes
The file to edit: `.github/workflows/release.yml`.
Today it has these jobs (simplified):
```
create-release
├── build-desktop-macos (Tauri .dmg/.tar.gz/.tar.gz.sig)
├── build-desktop-electron-macos (Electron .dmg/.zip/blockmap/latest-mac.yml)
├── publish-npm
├── combine-manifests (merges Tauri per-arch JSONs → latest.json)
├── combine-electron-manifests (merges Electron per-arch YMLs → latest-mac.yml)
└── finalize-release
```
### Step 1 — Remove the Tauri build
Delete these jobs entirely:
- `build-desktop-macos`
- `combine-manifests`
They are replaced by the repackage job (below). `finalize-release` `needs:`
list must be updated to drop both.
### Step 2 — Add a repackage job
Insert after `build-desktop-electron-macos`:
```yaml
repackage-electron-as-tauri-update:
needs: [create-release, build-desktop-electron-macos]
runs-on: macos-26
strategy:
fail-fast: false
matrix:
include:
- arch: arm64
platform: darwin-aarch64
- arch: x64
platform: darwin-x86_64
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v4
with:
node-version: '20'
# Pull the signed+notarized Electron .app that build-desktop-electron-macos
# already produced. Either re-download the dmg and mount+copy the .app, or
# (cleaner) modify build-desktop-electron-macos to upload the .app itself
# as an artifact so this job can download it. Prefer the latter — adds one
# `actions/upload-artifact@v4` step uploading `packages/electron/dist/mac-<arch>/OpenChamber.app`.
- name: Download signed Electron .app
uses: actions/download-artifact@v4
with:
name: electron-app-${{ matrix.arch }}
path: staged
- name: Install minisign
run: brew install minisign
- name: Tar and sign Electron .app as Tauri update payload
env:
TAURI_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VERSION: ${{ needs.create-release.outputs.version }}
run: |
set -euo pipefail
cd staged
# The tarball name convention Tauri's updater expects. Must end in
# `.app.tar.gz`. Name stays stable — Tauri updater does not care about
# the inner .app name.
TARBALL="OpenChamber.app.tar.gz"
tar -czf "$TARBALL" OpenChamber.app
# minisign needs the private key written to a file and a non-interactive
# password via -W (or env). The key in the secret is a minisign secret
# key block (base64-ish multi-line blob). Write to a file verbatim.
echo "$TAURI_KEY" > ../tauri-signing.key
echo "$TAURI_KEY_PASSWORD" | minisign -S -s ../tauri-signing.key \
-m "$TARBALL" -W
# Rename per platform so the release has distinct names for arm64/x64.
mv "$TARBALL" "OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz"
mv "${TARBALL}.minisig" "OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz.sig"
- name: Generate Tauri latest-<platform>.json
env:
VERSION: ${{ needs.create-release.outputs.version }}
REPO: ${{ github.repository }}
run: |
SIG=$(cat staged/OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz.sig)
TAR=OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz
cat > staged/latest-${{ matrix.platform }}.json <<EOF
{
"version": "${VERSION}",
"notes": "OpenChamber has moved to Electron. This update replaces the Tauri shell with the Electron build. Subsequent updates will be delivered via the Electron auto-updater.",
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"platforms": {
"${{ matrix.platform }}": {
"signature": "${SIG}",
"url": "https://github.com/${REPO}/releases/download/v${VERSION}/${TAR}"
}
}
}
EOF
- name: Upload tarball + sig to release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.create-release.outputs.version }}
files: |
staged/*.app.tar.gz
staged/*.app.tar.gz.sig
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload per-platform manifest as artifact for merge
uses: actions/upload-artifact@v4
with:
name: tauri-manifest-${{ matrix.platform }}
path: staged/latest-${{ matrix.platform }}.json
retention-days: 1
```
### Step 3 — Re-add the `combine-manifests` job
Bring it back (it was deleted in Step 1) but sourcing artifacts from the
repackage job instead of the old Tauri build. The merging logic is identical
to what the old job did. Minimum job shape:
```yaml
combine-manifests:
needs: [create-release, repackage-electron-as-tauri-update]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
pattern: tauri-manifest-*
path: artifacts
- name: Merge
run: |
# Copy the original merge logic from git history. It takes the two
# per-platform JSONs and produces a single `latest.json` with both
# platform entries. Upload as a release asset.
# Search git history: git log --all --diff-filter=D -- .github/workflows/release.yml
# Find the commit that deleted the old merge step and copy its shell block.
...
- uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.create-release.outputs.version }}
files: artifacts/latest.json
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```
### Step 4 — Update `finalize-release.needs`
```yaml
finalize-release:
needs: [create-release, build-desktop-electron-macos, repackage-electron-as-tauri-update, publish-npm, combine-manifests, combine-electron-manifests]
```
### Step 5 — Remove Tauri-specific code
After the transition release ships and has been out at least 2 weeks with no
rollback, remove:
- `packages/desktop/` (entire package — Tauri Rust + UI glue)
- Any `isTauriShell()` branches that are now dead code in
`packages/ui/src/` (search for the symbol; most call sites already fall
through to the Electron path because our preload exposes a `__TAURI__` shim;
audit each before removing).
- This file (`docs/TAURI_TO_ELECTRON_CUTOVER.md`) — mission accomplished.
Do this in a separate PR. Keep the transition release workflow intact until
the cleanup lands; rolling the cleanup into the transition release itself
makes debugging much harder if the migration misbehaves for a user.
## Validation before tagging the transition release
You must manually validate with a real Tauri install. Do NOT skip this.
1. Have the previous Tauri release installed locally
(`/Applications/OpenChamber.app` with `Contents/Info.plist` showing
`CFBundleIdentifier = ai.opencode.openchamber`).
2. Tag the transition release to a test tag
(e.g. `v2.0.0-migration-test`) and push.
3. Let the workflow complete. Do not merge cleanup PR yet.
4. In the running Tauri app, use the built-in "Check for updates".
5. Accept the update. The app should download, verify, extract, restart.
6. After restart, `Info.plist` under `/Applications/OpenChamber.app/` should
now show `CFBundleIdentifier = dev.openchamber.desktop`.
7. Settings should be intact: hosts list, default host, sessions history.
8. In the new Electron app, "Check for updates" should report no update
available (it's now at the transition version, which is the latest).
9. Produce a dummy v2.0.1 Electron-only release to prove the subsequent
Electron-path update works. Accept it. App relaunches into v2.0.1.
If any step fails:
- Delete the test tag and GitHub release.
- Do not delete yet-shipped artifacts from a real tag until rollback below.
## Rollback if the transition release misbehaves
If users report the Tauri → Electron update bricks their install:
1. **Immediately** delete the latest release asset
`OpenChamber-*.app.tar.gz` and `latest.json` from the GitHub release
(keep the DMGs so manual download still works).
2. Re-upload the previous version's `latest.json` as the current latest so
Tauri updaters see "up to date" instead of a broken update on next check.
3. Post a support note: users who already applied the broken update can
download a fresh Electron `.dmg` manually and drag-replace. Their
`~/.config/openchamber/settings.json` survives.
4. Investigate, fix the workflow, retry with a new version number.
## Risks & edge cases
### Different `CFBundleIdentifier` at same path
macOS LaunchServices caches identifier ↔ path mappings. When we replace
`ai.opencode.openchamber` with `dev.openchamber.desktop` at the same `.app`
path, LaunchServices will rebuild on next launch (automatic). Usually fine.
If a user's system is in a weird state, a `killall Dock` or logout/login
fixes it. Worth noting in the release notes.
### macOS notification permissions
Notification permission is per-bundle-id. After migration, the app has a new
bundle-id, so the first notification will re-prompt the user. Unavoidable.
Mention in release notes.
### Deep-link protocol registration
The `openchamber://` protocol was registered for `ai.opencode.openchamber`.
After migration, `dev.openchamber.desktop` registers itself on first launch.
LaunchServices updates the handler. Usually seamless. Test with
`open 'openchamber://session/test'` post-migration.
### Gatekeeper "damaged app" dialog
Rare. Triggered if the replaced `.app` fails a mid-extract codesign check.
Can happen if Tauri's extractor corrupts xattrs. Mitigation: test on a
pristine macOS install before tagging production.
### Users on unsupported old Tauri versions
If a user is on a very old Tauri build that doesn't know how to do the
fetch-verify-extract flow, they're stuck. Expected: negligibly few users;
they'll just stay on their old version forever until they manually download.
Acceptable.
### Rollback-after-migration-accepted is impossible per-user
Once a user is on Electron, the Tauri updater is gone. If they want to go
back to a Tauri build, they must manually download. We don't support this.
## Relevant files to understand before making changes
- `.github/workflows/release.yml` — the release workflow.
- `packages/electron/package.json` — electron-builder config (appId,
mac/dmg, publish, artifactName).
- `packages/electron/main.mjs` — autoUpdater setup (`setupAutoUpdater`,
`desktop_check_for_updates`, `desktop_download_and_install_update`,
`desktop_restart`). Understand this flow before touching the CI.
- `packages/electron/scripts/finalize-latest-yml.mjs` — per-arch
`latest-mac.yml` merger. Already wired in `combine-electron-manifests`.
- `packages/desktop/src-tauri/tauri.conf.json` — legacy Tauri identifier,
minisign pubkey embedded for updater verification. Don't modify; just
reference for context.
## Working protocol
Default to a dry-run (test tag like `vX.Y.Z-migration-test` on a workflow_dispatch
run) before the real tag. Surface only business-level decisions —
"cutover this release, or hold one more cycle?" — and make technical calls
(minisign invocation flags, YAML layout, job dependency order) yourself,
documenting each one in the PR description.
+10 -4
View File
@@ -1,6 +1,6 @@
{
"name": "openchamber-monorepo",
"version": "1.9.1",
"version": "1.9.9",
"description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes",
"private": true,
"type": "module",
@@ -26,14 +26,17 @@
"build:web": "bun run --cwd packages/web build",
"build:ui": "bun run --cwd packages/ui build",
"build:desktop": "bun run --cwd packages/desktop build",
"build:electron": "bun run --cwd packages/electron build",
"type-check": "bun run --filter '*' type-check",
"type-check:web": "bun run --cwd packages/web type-check",
"type-check:ui": "bun run --cwd packages/ui type-check",
"type-check:desktop": "bun run --cwd packages/desktop type-check",
"type-check:electron": "bun run --cwd packages/electron type-check",
"lint": "bun run --filter '*' lint",
"lint:web": "bun run --cwd packages/web lint",
"lint:ui": "bun run --cwd packages/ui lint",
"lint:desktop": "bun run --cwd packages/desktop lint",
"lint:electron": "bun run --cwd packages/electron lint",
"clean": "bun run --filter '*' clean",
"postinstall": "patch-package",
"dev:web": "bun run --cwd packages/web build:watch",
@@ -46,6 +49,8 @@
"desktop:stop-cli": "node ./packages/desktop/scripts/opencode-cli.mjs stop",
"desktop:dev": "node ./packages/desktop/scripts/desktop-dev.mjs",
"desktop:build": "bun run --cwd packages/desktop build:sidecar && bun run --cwd packages/desktop tauri build",
"electron:dev": "node ./packages/electron/scripts/electron-dev.mjs",
"electron:build": "bun run --cwd packages/electron package",
"desktop:lint": "bun run --cwd packages/desktop lint && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings",
"desktop:type-check": "bun run --cwd packages/desktop type-check && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings",
"vscode:dev": "node ./scripts/dev-vscode.mjs",
@@ -76,11 +81,11 @@
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.1",
"@codemirror/language": "6.12.2",
"@codemirror/lint": "^6.9.2",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.4",
"@codemirror/view": "^6.39.13",
"@codemirror/view": "6.39.13",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/ibm-plex-sans": "^5.1.1",
"@heroui/scroll-shadow": "^2.3.18",
@@ -89,7 +94,8 @@
"@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3",
"@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.3.0",
"@opencode-ai/sdk": "^1.4.25",
"@base-ui/react": "^1.4.0",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
+1 -14
View File
@@ -61,19 +61,6 @@
font-family: system-ui, -apple-system, sans-serif;
}
@keyframes logo-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
.logo-pulse {
animation: logo-pulse 3s ease-in-out infinite;
}
</style>
</head>
<body>
@@ -114,7 +101,7 @@
<path d="M70.784 74 L81.176 68 L81.176 80 L70.784 86 Z" fill="var(--splash-cell-fill)" opacity="0.4"/>
<path d="M81.176 68 L91.568 62 L91.568 74 L81.176 80 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
<path d="M50 2 L8.432 26 L50 50 L91.568 26 Z" fill="none" stroke="var(--splash-stroke)" stroke-width="2" stroke-linejoin="round"/>
<g class="logo-pulse" transform="matrix(0.866, 0.5, -0.866, 0.5, 50, 26) scale(0.75)">
<g transform="matrix(0.866, 0.5, -0.866, 0.5, 50, 26) scale(0.75)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M-16 -20 L16 -20 L16 20 L-16 20 Z M-8 -12 L-8 12 L8 12 L8 -12 Z" fill="var(--splash-logo-fill)"/>
<path d="M-8 -4 L8 -4 L8 12 L-8 12 Z" fill="var(--splash-logo-fill)" fill-opacity="0.4"/>
</g>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openchamber/desktop",
"version": "1.9.1",
"version": "1.9.9",
"private": true,
"type": "module",
"desktopPrerequisites": [
+1250 -771
View File
File diff suppressed because it is too large Load Diff
+17 -10
View File
@@ -1,6 +1,6 @@
[package]
name = "openchamber-desktop"
version = "1.9.1"
version = "1.9.9"
edition = "2021"
publish = false
@@ -16,20 +16,27 @@ devtools = ["tauri/devtools"]
anyhow = "1.0.86"
base64 = "0.22.1"
log = "0.4.28"
reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls", "blocking"] }
reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls", "json"] }
serde = { version = "1.0.210", features = ["derive"] }
serde_json = "1.0.143"
tauri = { version = "2.9.4", features = ["macos-private-api"] }
tauri-plugin-dialog = "2.4.2"
tauri-plugin-log = "2.7.1"
tauri-plugin-shell = "2.3.3"
tauri = { version = "2.10.3", features = ["macos-private-api"] }
tauri-plugin-dialog = "2.6.0"
tauri-plugin-log = "2.8.0"
tauri-plugin-shell = "2.3.5"
tauri-plugin-notification = "2.3.3"
tauri-plugin-updater = "2"
tokio = { version = "1.38", features = ["rt-multi-thread", "time"] }
tauri-plugin-updater = "2.10.0"
tokio = { version = "1.38", features = ["rt-multi-thread", "time", "macros", "sync"] }
url = "2.5"
[build-dependencies]
tauri-build = { version = "2.5.3", features = [] }
tauri-build = { version = "2.5.6", features = [] }
[profile.release]
lto = "thin"
codegen-units = 1
strip = true
[target.'cfg(target_os = "macos")'.dependencies]
window-vibrancy = "0.7.1"
objc2 = "0.6"
objc2-web-kit = "0.3"
rfd = "0.15"
File diff suppressed because it is too large Load Diff
@@ -22,6 +22,12 @@ const DEFAULT_READY_TIMEOUT_SEC: u64 = 30;
const DEFAULT_RECONNECT_MAX_ATTEMPTS: u32 = 5;
const MAX_LOG_LINES_PER_INSTANCE: usize = 1200;
/// Monitor starts with fast polling and relaxes to steady-state after stabilization.
const MONITOR_INITIAL_POLL_SECS: u64 = 2;
const MONITOR_STEADY_POLL_SECS: u64 = 10;
/// Number of healthy ticks before switching from initial to steady-state polling.
const MONITOR_STABILIZE_TICKS: u32 = 5;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DesktopSshInstancesConfig {
@@ -1027,6 +1033,7 @@ fn wait_for_master_ready(
master: &mut Child,
) -> Result<()> {
let deadline = std::time::Instant::now() + Duration::from_secs(timeout_sec as u64);
let mut poll_ms: u64 = 250;
while std::time::Instant::now() < deadline {
let args = vec![
"-o".to_string(),
@@ -1056,7 +1063,8 @@ fn wait_for_master_ready(
return Err(anyhow!(stderr.trim().to_string()));
}
std::thread::sleep(Duration::from_millis(250));
std::thread::sleep(Duration::from_millis(poll_ms));
poll_ms = (poll_ms * 2).min(2000);
}
Err(anyhow!("SSH ControlMaster connection timed out"))
@@ -1552,19 +1560,34 @@ fn is_local_tunnel_reachable(local_port: u16) -> bool {
}
fn wait_local_forward_ready(local_port: u16) -> Result<()> {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_millis(1000))
.no_proxy()
.build()?;
let deadline = std::time::Instant::now() + Duration::from_secs(DEFAULT_READY_TIMEOUT_SEC);
let target = format!("http://127.0.0.1:{local_port}/health");
let addr: std::net::SocketAddr = format!("127.0.0.1:{local_port}").parse()?;
let mut poll_ms: u64 = 250;
while std::time::Instant::now() < deadline {
if let Ok(response) = client.get(&target).send() {
if response.status().is_success() || response.status().as_u16() == 401 {
return Ok(());
if let Ok(mut stream) =
TcpStream::connect_timeout(&addr, Duration::from_millis(1000))
{
use std::io::{Read as IoRead, Write};
let _ = stream.set_read_timeout(Some(Duration::from_millis(1000)));
let _ = stream.set_write_timeout(Some(Duration::from_millis(1000)));
let request = format!(
"GET /health HTTP/1.1\r\nHost: 127.0.0.1:{local_port}\r\nConnection: close\r\n\r\n"
);
if stream.write_all(request.as_bytes()).is_ok() {
let mut buf = [0u8; 32];
if let Ok(n) = stream.read(&mut buf) {
let head = std::str::from_utf8(&buf[..n]).unwrap_or("");
// Match "HTTP/1.x 2xx" or "HTTP/1.x 401"
if head.starts_with("HTTP/1.")
&& (head.contains(" 2") || head.contains(" 401"))
{
return Ok(());
}
}
}
}
std::thread::sleep(Duration::from_millis(250));
std::thread::sleep(Duration::from_millis(poll_ms));
poll_ms = (poll_ms * 2).min(2000);
}
Err(anyhow!(
"Timed out waiting for forwarded OpenChamber health"
@@ -2353,8 +2376,14 @@ impl DesktopSshManagerInner {
let inner = Arc::clone(self);
let id_for_task = id.clone();
let handle = tauri::async_runtime::spawn(async move {
let mut healthy_ticks: u32 = 0;
loop {
tokio::time::sleep(Duration::from_secs(2)).await;
let poll_secs = if healthy_ticks >= MONITOR_STABILIZE_TICKS {
MONITOR_STEADY_POLL_SECS
} else {
MONITOR_INITIAL_POLL_SECS
};
tokio::time::sleep(Duration::from_secs(poll_secs)).await;
let mut dropped_reason: Option<String> = None;
let mut detached_notice: Option<String> = None;
@@ -2424,18 +2453,21 @@ impl DesktopSshManagerInner {
);
}
} else if session.master_detached {
if !is_control_master_alive(&session.parsed, &session.control_path) {
if is_local_tunnel_reachable(session.local_port) {
if detached_notice.is_none() {
detached_notice = Some(
"SSH ControlMaster check failed but local tunnel is still reachable"
.to_string(),
);
}
} else {
dropped_reason =
Some("SSH ControlMaster is not reachable".to_string());
}
// Fast path: check local tunnel first (cheap TCP probe)
// before spawning an SSH subprocess for control master check.
if is_local_tunnel_reachable(session.local_port) {
// Tunnel is alive — skip the expensive SSH check entirely.
} else if !is_control_master_alive(
&session.parsed,
&session.control_path,
) {
dropped_reason =
Some("SSH ControlMaster is not reachable".to_string());
} else {
detached_notice = Some(
"Local tunnel unreachable but ControlMaster is alive"
.to_string(),
);
}
} else if let Some(status) = session.master.try_wait().ok().flatten() {
if status.success()
@@ -2471,6 +2503,7 @@ impl DesktopSshManagerInner {
}
if dropped_reason.is_none() {
healthy_ticks = healthy_ticks.saturating_add(1);
continue;
}
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"productName": "OpenChamber",
"version": "1.9.1",
"version": "1.9.9",
"identifier": "ai.opencode.openchamber",
"build": {
"beforeDevCommand": "node ./scripts/dev-web-server.mjs",
@@ -15,7 +15,6 @@
"label": "main",
"create": false,
"title": "OpenChamber",
"transparent": true,
"width": 1280,
"height": 800,
"resizable": true,
@@ -28,8 +27,7 @@
"y": 26
},
"dragDropEnabled": false,
"visible": false,
"backgroundThrottling": "disabled"
"visible": false
}
],
"security": {
@@ -0,0 +1,347 @@
---
title: Reverse Proxy
description: Configure OpenChamber correctly behind Nginx, Nginx Proxy Manager, or another reverse proxy.
---
# Reverse Proxy
Use this page if you run OpenChamber behind Nginx, Nginx Proxy Manager, Caddy, Cloudflare, or another reverse proxy.
## Before you proxy it
1. Confirm OpenChamber works directly first.
2. Open `http://<server-ip>:3000` or your custom port from the same network.
3. Only add the reverse proxy after the direct connection works.
## What the proxy must support
- WebSockets for live message transport:
- `/api/event/ws`
- `/api/global/event/ws`
- `/api/terminal/ws`
- SSE without buffering:
- `/api/event`
- `/api/global/event`
- `/api/notifications/stream`
- `/api/openchamber/events`
- `/api/terminal/:sessionId/stream`
- Large request bodies for attachments and file operations
- Long-lived read timeouts for live streams and terminal sessions
## Rules that matter
- Enable WebSocket proxying.
- Disable buffering on SSE routes.
- Disable gzip on the proxy if OpenChamber is already compressing responses.
- Keep compression enabled in only one layer.
- Forward normal proxy headers such as `Host`, `X-Forwarded-For`, and `X-Forwarded-Proto`.
- Increase body size limits if users upload files.
## Quick checklist
- OpenChamber reachable directly on LAN
- WebSockets enabled in the proxy
- SSE routes have buffering off
- `gzip off` on the proxy host, or proxy compression disabled another way
- `client_max_body_size` large enough for attachments
- `proxy_read_timeout` long enough for streams
## Example: Nginx
<details>
<summary>Show example config</summary>
```nginx
client_max_body_size 50M;
client_body_buffer_size 50M;
proxy_request_buffering off;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
gzip off;
location = /api/terminal/ws {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location = /api/global/event/ws {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location = /api/event/ws {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location ~ ^/api/(event|global/event|notifications/stream|openchamber/events)$ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Accept "text/event-stream";
proxy_set_header Cache-Control "no-cache";
proxy_buffering off;
proxy_cache off;
gzip off;
add_header X-Accel-Buffering "no" always;
add_header Cache-Control "no-cache, no-transform" always;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location ~ ^/api/terminal/.+/stream$ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Accept "text/event-stream";
proxy_set_header Cache-Control "no-cache";
proxy_buffering off;
proxy_cache off;
gzip off;
add_header X-Accel-Buffering "no" always;
add_header Cache-Control "no-cache, no-transform" always;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location /api {
proxy_pass http://127.0.0.1:3000;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location / {
proxy_pass http://127.0.0.1:3000;
}
```
</details>
## Example: Nginx Proxy Manager
<details>
<summary>Show Advanced tab example</summary>
```nginx
client_max_body_size 50M;
client_body_buffer_size 50M;
proxy_request_buffering off;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
gzip off;
location = /api/terminal/ws {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 30s;
}
location = /api/global/event/ws {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 30s;
}
location = /api/event/ws {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 30s;
}
location = /api/event {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Accept "text/event-stream";
proxy_set_header Cache-Control "no-cache";
proxy_buffering off;
proxy_cache off;
gzip off;
add_header X-Accel-Buffering "no" always;
add_header Cache-Control "no-cache, no-transform" always;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 30s;
}
location = /api/global/event {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Accept "text/event-stream";
proxy_set_header Cache-Control "no-cache";
proxy_buffering off;
proxy_cache off;
gzip off;
add_header X-Accel-Buffering "no" always;
add_header Cache-Control "no-cache, no-transform" always;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 30s;
}
location = /api/notifications/stream {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Accept "text/event-stream";
proxy_set_header Cache-Control "no-cache";
proxy_buffering off;
proxy_cache off;
gzip off;
add_header X-Accel-Buffering "no" always;
add_header Cache-Control "no-cache, no-transform" always;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 30s;
}
location = /api/openchamber/events {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Accept "text/event-stream";
proxy_set_header Cache-Control "no-cache";
proxy_buffering off;
proxy_cache off;
gzip off;
add_header X-Accel-Buffering "no" always;
add_header Cache-Control "no-cache, no-transform" always;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 30s;
}
location ~ ^/api/terminal/.+/stream$ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Accept "text/event-stream";
proxy_set_header Cache-Control "no-cache";
proxy_buffering off;
proxy_cache off;
gzip off;
add_header X-Accel-Buffering "no" always;
add_header Cache-Control "no-cache, no-transform" always;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 30s;
}
location /api {
proxy_pass http://127.0.0.1:3000;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 30s;
}
location / {
proxy_pass http://127.0.0.1:3000;
}
```
</details>
Also enable `Websockets Support` in Nginx Proxy Manager for this host.
## Common failure signs
### Page loads, but sending messages fails
- WebSockets are not enabled in the proxy
- `/api/event/ws` or `/api/global/event/ws` is not passing through correctly
### Notifications or live status do not update
- one of the SSE routes is buffered or cached
- `X-Accel-Buffering "no"` is missing
### File uploads fail
- `client_max_body_size` is too small
### Everything works locally, but breaks only behind the proxy
- the proxy is compressing and buffering live traffic
- the proxy is missing WebSocket support
## Example: Caddy
<details>
<summary>Show example config</summary>
```caddy
reverse_proxy 127.0.0.1:3000 {
# WebSocket support is automatic in Caddy
# Flush SSE responses immediately
flush_interval -1
# Pass through Host and proxy headers
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
# Increase timeouts for long-lived streams
transport http {
read_timeout 3600s
write_timeout 3600s
}
}
```
</details>
Caddy handles WebSocket upgrades automatically — no extra configuration needed. The `flush_interval -1` directive ensures SSE chunks are forwarded immediately without buffering.
## CDN and double-compression warning
If you place a CDN (such as Cloudflare) in front of your reverse proxy, be aware of double compression:
- OpenChamber compresses HTTP responses with gzip (threshold 1 KB).
- Cloudflare and other CDNs also compress responses by default.
- This can cause double-compressed responses or incorrect `Content-Encoding` headers.
To avoid this, disable compression at **one** layer:
- **Cloudflare:** Rules → Compression → disable (or use "Passthrough" mode).
- **Nginx:** `gzip off` (already shown in the examples above).
- **Caddy:** Caddy does not re-compress by default if the upstream already sends compressed content.
SSE streaming routes are excluded from compression by OpenChamber, but the CDN may still buffer them. Check your CDN documentation for how to disable buffering on SSE paths.
## Related
- [Tunnels](/tunnels/)
- [Troubleshooting](/troubleshooting/)
+1
View File
@@ -18,6 +18,7 @@
{
"label": "Help",
"items": [
{ "label": "Reverse Proxy", "link": "/reverse-proxy/" },
{ "label": "Troubleshooting", "link": "/troubleshooting/" }
]
}
+13
View File
@@ -0,0 +1,13 @@
# Dependencies
node_modules/
# Electron build output
dist/
dist-bundle/
# Generated packaging resources
resources/web-dist/
resources/sidecar/
# OS-specific
.DS_Store
File diff suppressed because it is too large Load Diff
+98
View File
@@ -0,0 +1,98 @@
{
"name": "@openchamber/electron",
"version": "1.9.9",
"private": true,
"description": "Electron desktop runtime for OpenChamber",
"author": "OpenChamber",
"type": "module",
"main": "./dist-bundle/main.mjs",
"dependencies": {
"@openchamber/web": "workspace:*",
"electron-context-menu": "^4.1.2",
"electron-log": "^5.4.3",
"electron-updater": "^6.8.3"
},
"devDependencies": {
"@electron/rebuild": "^3.7.0",
"electron": "^41.2.1",
"electron-builder": "^26.0.0"
},
"desktopPrerequisites": [
"Electron runtime dependencies installed via bun install",
"Bun available for sidecar compilation",
"macOS build tools installed for notarized packaging"
],
"scripts": {
"dev": "node ./scripts/electron-dev.mjs",
"build:web-assets": "node ./scripts/build-web-assets.mjs",
"build": "bun -e \"process.exit(0)\"",
"bundle:main": "bun ./scripts/bundle-main.mjs",
"rebuild:native": "node ./scripts/rebuild-native.mjs",
"package": "bun run build:web-assets && bun run bundle:main && bun run rebuild:native && electron-builder",
"finalize:latest-yml": "node ./scripts/finalize-latest-yml.mjs",
"type-check": "node --check ./main.mjs && node --check ./preload.mjs",
"lint": "node -e \"process.exit(0)\""
},
"build": {
"appId": "dev.openchamber.desktop",
"productName": "OpenChamber",
"files": [
"dist-bundle/main.mjs",
"preload.mjs"
],
"extraResources": [
{
"from": "resources/web-dist",
"to": "web-dist"
}
],
"directories": {
"buildResources": "resources/icons",
"output": "dist"
},
"artifactName": "${productName}-${version}-${arch}.${ext}",
"npmRebuild": false,
"mac": {
"category": "public.app-category.developer-tools",
"icon": "resources/icons/icon.icns",
"hardenedRuntime": true,
"gatekeeperAssess": false,
"entitlements": "resources/entitlements.mac.plist",
"entitlementsInherit": "resources/entitlements.mac.plist",
"notarize": true,
"target": [
"dmg",
"zip"
]
},
"dmg": {
"sign": true,
"title": "${productName} ${version}",
"backgroundColor": "#FFFCF0",
"iconSize": 100,
"iconTextSize": 13,
"window": {
"width": 540,
"height": 340
},
"contents": [
{
"x": 180,
"y": 140,
"type": "file"
},
{
"x": 360,
"y": 140,
"type": "link",
"path": "/Applications"
}
]
},
"publish": {
"provider": "github",
"owner": "btriapitsyn",
"repo": "openchamber"
}
}
}
+146
View File
@@ -0,0 +1,146 @@
import { contextBridge, ipcRenderer } from 'electron';
const eventListeners = new Map();
const readArgValue = (name) => {
const prefix = `${name}=`;
const entry = process.argv.find((value) => typeof value === 'string' && value.startsWith(prefix));
if (!entry) {
return '';
}
return entry.slice(prefix.length);
};
const localOrigin = readArgValue('--openchamber-local-origin');
const homeDirectory = readArgValue('--openchamber-home');
const macosMajorRaw = readArgValue('--openchamber-macos-major');
const macosMajor = Number.parseInt(macosMajorRaw, 10);
// Preload re-executes on every cross-origin navigation (we run with
// sandbox:false, per-document). Two separate concerns to balance:
// - __OPENCHAMBER_ELECTRON__ is a shell-identity flag (no capability).
// Remote UIs still need it so isDesktopShell() returns true and the
// window renders with desktop affordances (DesktopHostSwitcher,
// title bar offsets, etc.). Expose unconditionally.
// - __TAURI__ is the IPC channel to the main process. Remote pages must
// not get it — otherwise any page loaded via DesktopHostSwitcher could
// read local files, open apps, relaunch, etc. Expose only on local
// pages (loopback / state.localOrigin / file:// for dev).
// Everything driven by localOrigin (home dir, macOS hints) also stays
// local-only since it leaks info about the Electron host machine.
const currentOrigin = (() => {
try {
return typeof location !== 'undefined' ? location.origin : '';
} catch {
return '';
}
})();
const isLoopbackOrigin = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/i.test(currentOrigin);
const isLocalPage = currentOrigin === 'null'
|| isLoopbackOrigin
|| (localOrigin && currentOrigin === localOrigin);
// Remote pages need __OPENCHAMBER_LOCAL_ORIGIN__ so the HostSwitcher knows
// the URL of the Local entry (isDesktopLocalOriginActive() falls back to
// window.location.origin otherwise — wrong on remote). Low risk: the value
// is just "http://127.0.0.1:<port>" which is not exploitable without the
// IPC channel, and CORS on the local server prevents remote-origin fetches.
if (localOrigin) {
contextBridge.exposeInMainWorld('__OPENCHAMBER_LOCAL_ORIGIN__', localOrigin);
}
// Home directory leaks the OS username — keep local-only. Remote pages
// operate on the REMOTE server's filesystem, local home is irrelevant
// (and would be misleading if consumed as a workspace hint).
if (isLocalPage && homeDirectory) {
contextBridge.exposeInMainWorld('__OPENCHAMBER_HOME__', homeDirectory);
}
// macOS major version drives window chrome offsets (traffic lights) — UI
// presentation only, safe to expose.
if (Number.isFinite(macosMajor) && macosMajor > 0) {
contextBridge.exposeInMainWorld('__OPENCHAMBER_MACOS_MAJOR__', macosMajor);
}
contextBridge.exposeInMainWorld('__OPENCHAMBER_ELECTRON__', {
runtime: 'electron',
});
// Note: bootOutcome must stay writable from the main world's initScript so
// re-navigations (host switch via deep link) can refresh it. contextBridge-
// exposed globals are read-only, which blocks that update — rely solely on
// the main-process initScript injection (dispatched on did-finish-load).
const addListener = (event, handler) => {
const listeners = eventListeners.get(event) || new Set();
listeners.add(handler);
eventListeners.set(event, listeners);
return () => {
const current = eventListeners.get(event);
if (!current) {
return;
}
current.delete(handler);
if (current.size === 0) {
eventListeners.delete(event);
}
};
};
const dispatchNativeEvent = (event, detail) => {
const listeners = eventListeners.get(event);
if (listeners) {
for (const listener of listeners) {
try {
listener({ payload: detail });
} catch (error) {
console.error(`[electron:preload] listener failed for ${event}:`, error);
}
}
}
try {
const domEvent = detail === undefined
? new Event(event)
: new CustomEvent(event, { detail });
window.dispatchEvent(domEvent);
} catch (error) {
console.error(`[electron:preload] failed to dispatch DOM event ${event}:`, error);
}
};
// Main-process events are read-only notifications (update progress,
// window focus, etc.) — safe to deliver to any page rendered in this
// webContents. The events themselves don't grant capability.
ipcRenderer.on('openchamber:emit', (_evt, payload) => {
if (!payload || typeof payload !== 'object') {
return;
}
const event = typeof payload.event === 'string' ? payload.event : '';
if (!event) {
return;
}
dispatchNativeEvent(event, payload.detail);
});
// __TAURI__ is exposed on all pages; the main-process gate in
// ipcMain.handle('openchamber:invoke') decides per-command what is safe
// for non-local callers (window/host-switcher ops yes, file/shell ops
// no). See COMMANDS_SAFE_FOR_REMOTE in main.mjs.
contextBridge.exposeInMainWorld('__TAURI__', {
core: {
invoke: (cmd, args) => ipcRenderer.invoke('openchamber:invoke', cmd, args || {}),
},
dialog: {
open: (options) => ipcRenderer.invoke('openchamber:dialog:open', options || {}),
},
shell: {
open: (url) => ipcRenderer.invoke('openchamber:invoke', 'desktop_open_external_url', { url }),
},
event: {
listen: async (event, handler) => addListener(event, handler),
},
});
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
Intentionally NOT sandboxed. This app is distributed outside the Mac App Store.
Do not add com.apple.security.app-sandbox.
These entitlements are commonly required for Electron/Chromium JIT behavior
under hardened runtime, plus access the app already relies on (microphone for
voice notes / dictation, network client for sidecar + remote hosts, shell
probing for PATH inheritance).
-->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-executable-page-protection</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.inherit</key>
<true/>
</dict>
</plist>
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="iconShadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="12" stdDeviation="14" flood-opacity="0.5" flood-color="#000000"/>
</filter>
<linearGradient id="bgGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#303030"/>
<stop offset="100%" stop-color="#141414"/>
</linearGradient>
</defs>
<!-- Icon with Apple standard padding (100px on each side) -->
<g transform="translate(100, 100)">
<!-- Background rounded square - 824x824 (Apple standard) - dark gradient -->
<rect x="0" y="0" width="824" height="824" rx="185" ry="185" fill="url(#bgGradient)" filter="url(#iconShadow)"/>
<!-- OpenChamber logo centered - simplified for dock visibility -->
<g transform="translate(412, 412) scale(6.5)">
<!-- Left face - simplified, no grid cells -->
<path d="M0 0 L-41.568 -24 L-41.568 24 L0 48 Z" fill="white" fill-opacity="0.2" stroke="white" stroke-width="3" stroke-linejoin="round"/>
<!-- Right face - simplified, no grid cells -->
<path d="M0 0 L41.568 -24 L41.568 24 L0 48 Z" fill="white" fill-opacity="0.35" stroke="white" stroke-width="3" stroke-linejoin="round"/>
<!-- Top face - open -->
<path d="M0 -48 L-41.568 -24 L0 0 L41.568 -24 Z" fill="none" stroke="white" stroke-width="3" stroke-linejoin="round"/>
<!-- OpenCode logo on top face -->
<g transform="matrix(0.866, 0.5, -0.866, 0.5, 0, -24) scale(0.75)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M-16 -20 L16 -20 L16 20 L-16 20 Z M-8 -12 L-8 12 L8 12 L8 -12 Z" fill="white"/>
<path d="M-8 -4 L8 -4 L8 12 L-8 12 Z" fill="white" fill-opacity="0.4"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

@@ -0,0 +1,75 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..', '..', '..');
const webDir = path.join(repoRoot, 'packages', 'web');
const electronDir = path.join(repoRoot, 'packages', 'electron');
const resourcesDir = path.join(electronDir, 'resources');
const resourcesWebDistDir = path.join(resourcesDir, 'web-dist');
const webDistDir = path.join(webDir, 'dist');
const run = (cmd, args, cwd) => {
const result = spawnSync(cmd, args, { cwd, stdio: 'inherit' });
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(`Command failed: ${cmd} ${args.join(' ')}`);
}
};
const resolveBun = () => {
if (typeof process.env.BUN === 'string' && process.env.BUN.trim()) {
return process.env.BUN.trim();
}
const result = spawnSync('/bin/bash', ['-lc', 'command -v bun'], { encoding: 'utf8' });
const resolved = (result.stdout || '').trim();
return resolved || 'bun';
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const removeDir = async (target) => {
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
await fs.rm(target, { recursive: true, force: true });
return;
} catch (error) {
if (attempt === 4) throw error;
if (!['ENOTEMPTY', 'EBUSY', 'EPERM'].includes(error?.code)) throw error;
await sleep(100 * (attempt + 1));
}
}
};
const copyDir = async (src, dst) => {
await fs.mkdir(dst, { recursive: true });
const entries = await fs.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const from = path.join(src, entry.name);
const to = path.join(dst, entry.name);
if (entry.isDirectory()) {
await copyDir(from, to);
} else {
await fs.copyFile(from, to);
}
}
};
const bunExe = resolveBun();
console.log('[electron] building web UI dist...');
run(bunExe, ['run', 'build'], webDir);
console.log('[electron] staging packaged resources...');
await fs.mkdir(resourcesDir, { recursive: true });
const stagedWebDistDir = await fs.mkdtemp(path.join(resourcesDir, 'web-dist-staging-'));
await copyDir(webDistDir, stagedWebDistDir);
await removeDir(resourcesWebDistDir);
await fs.rename(stagedWebDistDir, resourcesWebDistDir);
console.log(`[electron] web assets ready: ${resourcesWebDistDir}`);
@@ -0,0 +1,44 @@
/**
* Bundle main.mjs into a single file. Small electron-* helper deps are
* inlined; everything else including the in-process web server
* (@openchamber/web) and native modules stays external so it resolves
* from node_modules at runtime inside the packaged app.
*
* Why external matters: packages/web/server pulls in bun-pty, which has
* a top-level `import { dlopen } from "bun:ffi"`. If we inline it here,
* Node's ESM loader sees `bun:ffi` at package load time and crashes with
* ERR_UNSUPPORTED_ESM_URL_SCHEME before any runtime guard can skip it.
* Leaving @openchamber/web external means the conditional
* `if (isBunRuntime) await import('bun-pty')` stays dynamic and is never
* reached under Electron.
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
const result = await Bun.build({
entrypoints: [path.join(root, 'main.mjs')],
outdir: path.join(root, 'dist-bundle'),
target: 'node',
format: 'esm',
external: [
'electron',
'@openchamber/web',
'@openchamber/web/*',
'bun-pty',
'node-pty',
'better-sqlite3',
],
minify: false,
sourcemap: 'none',
naming: '[name].mjs',
});
if (!result.success) {
for (const msg of result.logs) console.error(msg);
process.exit(1);
}
console.log('[electron] main.mjs bundled -> dist-bundle/main.mjs');
@@ -0,0 +1,131 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '../../..');
const electronDir = path.join(repoRoot, 'packages/electron');
function spawnProcess(command, args, options = {}) {
return spawn(command, args, {
cwd: repoRoot,
env: { ...process.env, OPENCHAMBER_ELECTRON_DEV: '1' },
stdio: 'inherit',
detached: process.platform !== 'win32',
...options,
});
}
function waitForExit(child, timeoutMs) {
return new Promise((resolve) => {
if (!child || child.exitCode !== null || child.signalCode !== null) {
resolve();
return;
}
const onExit = () => {
clearTimeout(timer);
resolve();
};
const timer = setTimeout(() => {
child.off('exit', onExit);
resolve();
}, timeoutMs);
child.once('exit', onExit);
});
}
function signalChild(child, signal) {
if (!child || child.exitCode !== null || child.signalCode !== null) {
return;
}
try {
if (process.platform !== 'win32') {
process.kill(-child.pid, signal);
return;
}
} catch {
}
try {
child.kill(signal);
} catch {
}
}
async function stopChildTree(child) {
if (!child || child.exitCode !== null || child.signalCode !== null) {
return;
}
signalChild(child, 'SIGINT');
await waitForExit(child, 2500);
if (child.exitCode === null && child.signalCode === null) {
signalChild(child, 'SIGTERM');
await waitForExit(child, 2500);
}
if (child.exitCode === null && child.signalCode === null) {
signalChild(child, 'SIGKILL');
await waitForExit(child, 1000);
}
}
async function main() {
const devServer = spawnProcess('node', ['./scripts/dev-web-hmr.mjs'], {
env: {
...process.env,
OPENCHAMBER_ELECTRON_DEV: '1',
OPENCHAMBER_HMR_UI_PORT: '5173',
OPENCHAMBER_HMR_API_PORT: '3901',
OPENCHAMBER_DISABLE_PWA_DEV: '1',
},
});
const electron = spawnProcess('npx', ['electron', './main.mjs'], { cwd: electronDir });
let cleaning = false;
const teardown = async (code) => {
if (cleaning) {
return;
}
cleaning = true;
await Promise.all([stopChildTree(electron), stopChildTree(devServer)]);
process.exit(typeof code === 'number' ? code : 0);
};
const onChildExit = (label) => (code, signal) => {
if (code !== 0 || signal) {
console.warn(`[electron:dev] ${label} exited with code ${code ?? 'null'} signal ${signal ?? 'none'}.`);
}
void teardown(code ?? 1);
};
devServer.on('exit', onChildExit('dev server'));
electron.on('exit', onChildExit('electron'));
devServer.on('error', (error) => {
console.error('[electron:dev] failed to start dev server:', error);
void teardown(1);
});
electron.on('error', (error) => {
console.error('[electron:dev] failed to start electron:', error);
void teardown(1);
});
for (const [signal, exitCode] of Object.entries({ SIGINT: 130, SIGTERM: 143, SIGQUIT: 131, SIGHUP: 129 })) {
process.on(signal, () => {
void teardown(exitCode);
});
}
}
main().catch((error) => {
console.error('[electron:dev] unexpected error:', error);
process.exit(1);
});
@@ -0,0 +1,113 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
const dir = process.env.LATEST_YML_DIR;
const repo = process.env.GH_REPO;
const version = process.env.OPENCHAMBER_VERSION;
if (!dir) throw new Error('LATEST_YML_DIR is required');
if (!repo) throw new Error('GH_REPO is required');
if (!version) throw new Error('OPENCHAMBER_VERSION is required');
const parse = (content) => {
const lines = content.split('\n');
let releaseDate = '';
let parsedVersion = '';
const files = [];
let current;
const flush = () => {
if (current?.url && current?.sha512 && current?.size) {
files.push(current);
}
current = undefined;
};
for (const line of lines) {
const trimmed = line.trim();
const indented = line.startsWith(' ') || line.startsWith(' -');
if (line.startsWith('version:')) {
parsedVersion = line.slice('version:'.length).trim();
} else if (line.startsWith('releaseDate:')) {
releaseDate = line.slice('releaseDate:'.length).trim().replace(/^'|'$/g, '');
} else if (trimmed.startsWith('- url:')) {
flush();
current = { url: trimmed.slice('- url:'.length).trim() };
} else if (indented && current && trimmed.startsWith('sha512:')) {
current.sha512 = trimmed.slice('sha512:'.length).trim();
} else if (indented && current && trimmed.startsWith('size:')) {
current.size = Number(trimmed.slice('size:'.length).trim());
} else if (indented && current && trimmed.startsWith('blockMapSize:')) {
current.blockMapSize = Number(trimmed.slice('blockMapSize:'.length).trim());
} else if (!indented && current) {
flush();
}
}
flush();
return { version: parsedVersion, releaseDate, files };
};
const serialize = (data) => {
const lines = [`version: ${data.version}`, 'files:'];
for (const file of data.files) {
lines.push(` - url: ${file.url}`);
lines.push(` sha512: ${file.sha512}`);
lines.push(` size: ${file.size}`);
if (file.blockMapSize) {
lines.push(` blockMapSize: ${file.blockMapSize}`);
}
}
lines.push(`releaseDate: '${data.releaseDate}'`);
return `${lines.join('\n')}\n`;
};
const read = async (subdir, filename) => {
const filePath = path.join(dir, subdir, filename);
try {
return parse(await fs.readFile(filePath, 'utf8'));
} catch {
return null;
}
};
const output = {};
const winX64 = await read('latest-yml-x86_64-pc-windows-msvc', 'latest.yml');
const winArm64 = await read('latest-yml-aarch64-pc-windows-msvc', 'latest.yml');
if (winX64 || winArm64) {
const base = winArm64 || winX64;
output['latest.yml'] = serialize({
version: base.version,
files: [...(winArm64?.files || []), ...(winX64?.files || [])],
releaseDate: base.releaseDate,
});
}
const linuxX64 = await read('latest-yml-x86_64-unknown-linux-gnu', 'latest-linux.yml');
if (linuxX64) output['latest-linux.yml'] = serialize(linuxX64);
const linuxArm64 = await read('latest-yml-aarch64-unknown-linux-gnu', 'latest-linux-arm64.yml');
if (linuxArm64) output['latest-linux-arm64.yml'] = serialize(linuxArm64);
const macX64 = await read('latest-yml-x86_64-apple-darwin', 'latest-mac.yml');
const macArm64 = await read('latest-yml-aarch64-apple-darwin', 'latest-mac.yml');
if (macX64 || macArm64) {
const base = macArm64 || macX64;
output['latest-mac.yml'] = serialize({
version: base.version,
files: [...(macArm64?.files || []), ...(macX64?.files || [])],
releaseDate: base.releaseDate,
});
}
const tag = `v${version}`;
const tmp = process.env.RUNNER_TEMP || '/tmp';
for (const [filename, content] of Object.entries(output)) {
const outputPath = path.join(tmp, filename);
await fs.writeFile(outputPath, content);
console.log(`prepared ${outputPath} for upload to ${repo} release ${tag}`);
}
console.log('finalized latest yml files');
@@ -0,0 +1,30 @@
#!/usr/bin/env node
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { rebuild } from '@electron/rebuild';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const electronDir = path.resolve(__dirname, '..');
const repoRoot = path.resolve(electronDir, '..', '..');
const require = createRequire(import.meta.url);
const electronPkg = require('electron/package.json');
const electronVersion = electronPkg.version;
console.log(`[electron] rebuilding native modules against Electron ${electronVersion}...`);
// Rebuild against the hoisted root node_modules (bun workspace layout).
// force=true re-links regardless of cached state; prebuild-install lookup is
// bypassed by @electron/rebuild in favor of direct node-gyp builds.
await rebuild({
buildPath: repoRoot,
electronVersion,
force: true,
arch: process.env.ELECTRON_BUILDER_ARCH || process.arch,
onlyModules: ['better-sqlite3', 'node-pty', 'bun-pty'],
});
console.log('[electron] native modules rebuilt successfully');
File diff suppressed because it is too large Load Diff
+14 -17
View File
@@ -1,6 +1,6 @@
{
"name": "@openchamber/ui",
"version": "1.9.1",
"version": "1.9.9",
"private": true,
"type": "module",
"main": "src/main.tsx",
@@ -25,13 +25,13 @@
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.1",
"@codemirror/language": "6.12.2",
"@codemirror/language-data": "^6.5.2",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/lint": "^6.9.2",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.4",
"@codemirror/view": "^6.39.13",
"@codemirror/view": "6.39.13",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -39,20 +39,11 @@
"@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "^1.3.0",
"@opencode-ai/sdk": "^1.4.25",
"@pierre/diffs": "1.1.0-beta.13",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.8",
"@base-ui/react": "^1.4.0",
"@remixicon/react": "^4.7.0",
"@streamdown/code": "^1.0.2",
"@simplewebauthn/browser": "13.3.0",
"@tanstack/react-virtual": "^3.13.18",
"@types/react-syntax-highlighter": "^15.5.13",
"beautiful-mermaid": "^1.1.3",
@@ -60,12 +51,18 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"codemirror-lang-elixir": "^4.0.0",
"dompurify": "^3.2.7",
"express": "^5.1.0",
"fuse.js": "^7.1.0",
"ghostty-web": "^0.4.0",
"heic2any": "^0.0.4",
"html-to-image": "^1.11.13",
"http-proxy-middleware": "^3.0.5",
"katex": "^0.16.21",
"marked": "^17.0.3",
"morphdom": "^2.7.7",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0",
"motion": "^12.23.24",
"next-themes": "^0.4.6",
"prismjs": "^1.30.0",
@@ -73,9 +70,9 @@
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-syntax-highlighter": "^15.6.6",
"remend": "^1.2.1",
"simple-git": "^3.28.0",
"sonner": "^2.0.7",
"streamdown": "^2.2.0",
"strip-json-comments": "^5.0.3",
"tailwind-merge": "^3.3.1",
"yaml": "^2.8.1",
@@ -85,7 +82,7 @@
"devDependencies": {
"@eslint/js": "^9.33.0",
"@tailwindcss/postcss": "^4.0.0",
"@tauri-apps/api": "^2.9.0",
"@tauri-apps/api": "^2.10.1",
"@types/node": "^24.3.1",
"@types/prismjs": "^1.26.6",
"@types/qrcode": "^1.5.5",
+567 -186
View File
@@ -5,31 +5,45 @@ import { AgentManagerView } from '@/components/views/agent-manager';
import { ChatView } from '@/components/views';
import { FireworksProvider } from '@/contexts/FireworksContext';
import { Toaster } from '@/components/ui/sonner';
import { Button } from '@/components/ui/button';
import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel';
import { setStreamPerfEnabled } from '@/stores/utils/streamDebug';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { useEventStream } from '@/hooks/useEventStream';
// useEventStream removed — replaced by SyncProvider + SyncBridge
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
import { useMenuActions } from '@/hooks/useMenuActions';
import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
import { useServerSessionStatus } from '@/hooks/useServerSessionStatus';
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
import { useQueuedMessageAutoSend } from '@/hooks/useQueuedMessageAutoSend';
import { useRouter } from '@/hooks/useRouter';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { usePwaManifestSync } from '@/hooks/usePwaManifestSync';
import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt';
import { useWindowControlsOverlayLayout } from '@/hooks/useWindowControlsOverlayLayout';
import { useWindowTitle } from '@/hooks/useWindowTitle';
import { useGitHubPrBackgroundTracking } from '@/hooks/useGitHubPrBackgroundTracking';
import { GitPollingProvider } from '@/hooks/useGitPolling';
import { useConfigStore } from '@/stores/useConfigStore';
import { hasModifier } from '@/lib/utils';
import { isDesktopLocalOriginActive, isDesktopShell } from '@/lib/desktop';
import { OnboardingScreen } from '@/components/onboarding/OnboardingScreen';
import { useSessionStore } from '@/stores/useSessionStore';
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell, restartDesktopApp } from '@/lib/desktop';
import {
getInjectedBootOutcome,
getBootInjectionStatus,
resolveDesktopBootView,
canDismissInitialLoading,
shouldRestartDesktopBootFlow,
type BootInjectionStatus,
type DesktopBootView,
} from '@/lib/desktopBoot';
import type { RecoveryVariant } from '@/components/onboarding/DesktopConnectionRecovery';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { opencodeClient } from '@/lib/opencode/client';
import { SyncProvider, useSessions } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
import { setOptimisticRefs } from '@/sync/session-actions';
import { useFontPreferences } from '@/hooks/useFontPreferences';
import { CODE_FONT_OPTION_MAP, DEFAULT_MONO_FONT, DEFAULT_UI_FONT, UI_FONT_OPTION_MAP } from '@/lib/fontOptions';
import { loadMonoFont, loadUiFont } from '@/lib/fontLoader';
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
import { AboutDialog } from '@/components/ui/AboutDialog';
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
@@ -37,15 +51,23 @@ import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { VoiceProvider } from '@/components/voice';
import { useUIStore } from '@/stores/useUIStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import type { RuntimeAPIs } from '@/lib/api/types';
import { TooltipProvider } from '@/components/ui/tooltip';
import { QuickOpenDialog } from '@/components/ui/QuickOpenDialog';
import { McpOAuthCallbackPage } from '@/components/sections/mcp/McpOAuthCallbackPage';
import { MCP_OAUTH_CALLBACK_PATH } from '@/components/sections/mcp/mcpOAuth';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import { useI18n } from '@/lib/i18n';
const CLI_MISSING_ERROR_REGEX =
/ENOENT|spawn\s+opencode|Unable\s+to\s+locate\s+the\s+opencode\s+CLI|OpenCode\s+CLI\s+not\s+found|opencode(\.exe)?\s+not\s+found|opencode(\.exe)?:\s*command\s+not\s+found|not\s+recognized\s+as\s+an\s+internal\s+or\s+external\s+command|env:\s*['"]?(node|bun)['"]?:\s*No\s+such\s+file\s+or\s+directory|(node|bun):\s*No\s+such\s+file\s+or\s+directory/i;
const CLI_ONBOARDING_HEALTH_POLL_MS = 1500;
// Lazy-loaded heavy views — loaded on demand to reduce initial bundle size.
const OnboardingScreen = lazyWithChunkRecovery(() =>
import('@/components/onboarding/OnboardingScreen').then((m) => ({ default: m.OnboardingScreen })),
);
const AboutDialogWrapper: React.FC = () => {
const { isAboutDialogOpen, setAboutDialogOpen } = useUIStore();
const isAboutDialogOpen = useUIStore((s) => s.isAboutDialogOpen);
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
return (
<AboutDialog
open={isAboutDialogOpen}
@@ -54,6 +76,27 @@ const AboutDialogWrapper: React.FC = () => {
);
};
const StartupInitializationRecovery: React.FC<{
onRetry: () => void;
isRetrying: boolean;
}> = ({ onRetry, isRetrying }) => {
const { t } = useI18n();
return (
<div className="flex h-full items-center justify-center bg-background px-6 text-foreground">
<div className="flex max-w-md flex-col items-center gap-4 text-center">
<div className="flex flex-col gap-2">
<h1 className="typography-title text-foreground">{t('startup.initRecovery.title')}</h1>
<p className="typography-body text-muted-foreground">{t('startup.initRecovery.description')}</p>
</div>
<Button type="button" onClick={onRetry} disabled={isRetrying}>
{isRetrying ? t('startup.initRecovery.retrying') : t('startup.initRecovery.retry')}
</Button>
</div>
</div>
);
};
type AppProps = {
apis: RuntimeAPIs;
};
@@ -94,16 +137,80 @@ const readEmbeddedSessionChatConfig = (): EmbeddedSessionChatConfig | null => {
};
};
const isMcpOAuthCallbackPath = (): boolean => {
if (typeof window === 'undefined') {
return false;
}
return window.location.pathname === MCP_OAUTH_CALLBACK_PATH;
};
const EmbeddedSessionSelectionGate: React.FC<{
embeddedSessionChat: EmbeddedSessionChatConfig | null;
isVSCodeRuntime: boolean;
}> = ({ embeddedSessionChat, isVSCodeRuntime }) => {
const sessions = useSessions();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
React.useEffect(() => {
if (!embeddedSessionChat || isVSCodeRuntime) {
return;
}
if (currentSessionId === embeddedSessionChat.sessionId) {
return;
}
if (!sessions.some((session) => session.id === embeddedSessionChat.sessionId)) {
return;
}
void setCurrentSession(embeddedSessionChat.sessionId);
}, [currentSessionId, embeddedSessionChat, isVSCodeRuntime, sessions, setCurrentSession]);
return null;
};
const SyncOptimisticBridge: React.FC = () => {
const sync = useSync();
const addRef = React.useRef(sync.optimistic.add);
const removeRef = React.useRef(sync.optimistic.remove);
addRef.current = sync.optimistic.add;
removeRef.current = sync.optimistic.remove;
React.useEffect(() => {
setOptimisticRefs(
(input) => addRef.current(input),
(input) => removeRef.current(input),
);
}, []);
return null;
};
function SyncAppEffects({ embeddedBackgroundWorkEnabled }: {
embeddedBackgroundWorkEnabled: boolean;
}) {
usePwaManifestSync();
useWindowControlsOverlayLayout();
useSessionAutoCleanup(embeddedBackgroundWorkEnabled);
useQueuedMessageAutoSend(embeddedBackgroundWorkEnabled);
useKeyboardShortcuts();
return <SyncOptimisticBridge />;
}
function App({ apis }: AppProps) {
const { initializeApp, isInitialized, isConnected } = useConfigStore();
const initializeApp = useConfigStore((s) => s.initializeApp);
const isInitialized = useConfigStore((s) => s.isInitialized);
const isConnected = useConfigStore((s) => s.isConnected);
const providersCount = useConfigStore((state) => state.providers.length);
const agentsCount = useConfigStore((state) => state.agents.length);
const loadProviders = useConfigStore((state) => state.loadProviders);
const loadAgents = useConfigStore((state) => state.loadAgents);
const { error, clearError, loadSessions } = useSessionStore();
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
const sessions = useSessionStore((state) => state.sessions);
const error = useSessionUIStore((s) => s.error);
const clearError = useSessionUIStore((s) => s.clearError);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const setDirectory = useDirectoryStore((state) => state.setDirectory);
const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory);
@@ -111,12 +218,33 @@ function App({ apis }: AppProps) {
const { uiFont, monoFont } = useFontPreferences();
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode);
const [showCliOnboarding, setShowCliOnboarding] = React.useState(false);
const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(true);
const [initRetryExhausted, setInitRetryExhausted] = React.useState(false);
const [initRetryEpoch, setInitRetryEpoch] = React.useState(0);
const [manualInitRetrying, setManualInitRetrying] = React.useState(false);
const isDesktopRuntime = React.useMemo(() => isDesktopShell(), []);
const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled);
const [bootInjectionStatus, setBootInjectionStatus] = React.useState<BootInjectionStatus>(() => {
return getBootInjectionStatus();
});
const [bootView, setBootView] = React.useState<DesktopBootView | null>(() => {
const outcome = getInjectedBootOutcome();
return outcome !== null
? resolveDesktopBootView({ isDesktopShell: true, bootOutcome: outcome })
: null;
});
const appReadyDispatchedRef = React.useRef(false);
const initializationInFlightRef = React.useRef(false);
const embeddedSessionChat = React.useMemo<EmbeddedSessionChatConfig | null>(() => readEmbeddedSessionChatConfig(), []);
const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible;
const isMcpOAuthCallback = React.useMemo(() => isMcpOAuthCallbackPath(), []);
React.useEffect(() => {
setStreamPerfEnabled(showMemoryDebug);
return () => {
setStreamPerfEnabled(false);
};
}, [showMemoryDebug]);
React.useEffect(() => {
setIsVSCodeRuntime(apis.runtime.isVSCode);
@@ -135,8 +263,6 @@ function App({ apis }: AppProps) {
void refreshGitHubAuthStatus(apis.github, { force: true });
}, [apis.github, embeddedSessionChat, refreshGitHubAuthStatus]);
useGitHubPrBackgroundTracking(embeddedBackgroundWorkEnabled ? apis.github : undefined, apis.git);
React.useEffect(() => {
if (typeof document === 'undefined') {
return;
@@ -144,6 +270,8 @@ function App({ apis }: AppProps) {
const root = document.documentElement;
const uiStack = UI_FONT_OPTION_MAP[uiFont]?.stack ?? UI_FONT_OPTION_MAP[DEFAULT_UI_FONT].stack;
const monoStack = CODE_FONT_OPTION_MAP[monoFont]?.stack ?? CODE_FONT_OPTION_MAP[DEFAULT_MONO_FONT].stack;
void loadUiFont(uiFont);
void loadMonoFont(monoFont);
root.style.setProperty('--font-sans', uiStack);
root.style.setProperty('--font-heading', uiStack);
@@ -157,25 +285,55 @@ function App({ apis }: AppProps) {
}
}, [uiFont, monoFont]);
const bootOutcomeKnown = bootInjectionStatus === 'valid';
const bootViewIsMain = bootView?.screen === 'main';
// Splash dismissal: use the authoritative loading gate from desktopBoot.
// Desktop shells strictly require a valid boot outcome before dismissing.
// Non-main outcomes (chooser/recovery) can dismiss without waiting for init.
React.useEffect(() => {
if (isInitialized) {
const hideInitialLoading = () => {
const loadingElement = document.getElementById('initial-loading');
if (loadingElement) {
loadingElement.classList.add('fade-out');
setTimeout(() => {
loadingElement.remove();
}, 300);
}
};
const timer = setTimeout(hideInitialLoading, 150);
return () => clearTimeout(timer);
if (!canDismissInitialLoading({
isDesktopShell: isDesktopRuntime,
isInitialized,
bootOutcomeKnown,
bootViewIsMain,
})) {
return;
}
}, [isInitialized]);
const timer = setTimeout(() => {
const loadingElement = document.getElementById('initial-loading');
if (loadingElement) {
loadingElement.classList.add('fade-out');
setTimeout(() => {
loadingElement.remove();
}, 300);
}
}, 150);
return () => clearTimeout(timer);
}, [isDesktopRuntime, isInitialized, bootOutcomeKnown, bootViewIsMain]);
// Deterministic malformed handling: update splash text so the user
// sees a specific error instead of a generic spinner, but do NOT
// dismiss the splash (that only happens on a valid outcome).
React.useEffect(() => {
if (!isDesktopRuntime || bootInjectionStatus !== 'malformed') {
return;
}
const loadingElement = document.getElementById('initial-loading');
if (loadingElement) {
loadingElement.textContent = 'Desktop startup failed — please restart the app.';
}
}, [isDesktopRuntime, bootInjectionStatus]);
// Non-desktop fallback: remove splash after 5 seconds even if init stalls.
React.useEffect(() => {
if (isDesktopRuntime) {
return;
}
const fallbackTimer = setTimeout(() => {
const loadingElement = document.getElementById('initial-loading');
if (loadingElement && !isInitialized) {
@@ -187,7 +345,29 @@ function App({ apis }: AppProps) {
}, 5000);
return () => clearTimeout(fallbackTimer);
}, [isInitialized]);
}, [isDesktopRuntime, isInitialized]);
React.useEffect(() => {
let cancelled = false;
const run = async () => {
const res = await fetch('/health', { method: 'GET' }).catch(() => null);
if (!res || !res.ok || cancelled) return;
const data = (await res.json().catch(() => null)) as null | {
planModeExperimentalEnabled?: unknown;
};
if (!data || cancelled) return;
const raw = data.planModeExperimentalEnabled;
const enabled = raw === true || raw === 1 || raw === '1' || raw === 'true';
setPlanModeEnabled(enabled);
};
void run();
return () => {
cancelled = true;
};
}, [setPlanModeEnabled]);
React.useEffect(() => {
const init = async () => {
@@ -196,76 +376,138 @@ function App({ apis }: AppProps) {
if (isVSCodeRuntime) {
return;
}
await initializeApp();
if (initializationInFlightRef.current) {
return;
}
initializationInFlightRef.current = true;
try {
await initializeApp();
} finally {
initializationInFlightRef.current = false;
}
};
init();
}, [initializeApp, isVSCodeRuntime]);
const startupRecoveryInProgressRef = React.useRef(false);
const startupRecoveryLastAttemptRef = React.useRef(0);
React.useEffect(() => {
if (isVSCodeRuntime) {
return;
}
if (!isConnected) {
return;
}
if (providersCount > 0 && agentsCount > 0) {
return;
}
if (startupRecoveryInProgressRef.current) {
return;
}
if (isVSCodeRuntime || isInitialized) return;
const now = Date.now();
if (now - startupRecoveryLastAttemptRef.current < 750) {
return;
}
let active = true;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let retryCount = 0;
const MAX_RETRIES = 10;
const BASE_DELAY_MS = 1000;
startupRecoveryLastAttemptRef.current = now;
startupRecoveryInProgressRef.current = true;
const repair = async () => {
try {
if (providersCount === 0) {
await loadProviders();
}
if (agentsCount === 0) {
await loadAgents();
}
} catch {
// Keep UI responsive; we'll retry on next cycle.
} finally {
startupRecoveryInProgressRef.current = false;
const retryInitialization = async () => {
if (!active) return;
if (retryCount >= MAX_RETRIES) {
setInitRetryExhausted(true);
return;
}
const state = useConfigStore.getState();
if (state.isInitialized) {
setInitRetryExhausted(false);
return;
}
if (initializationInFlightRef.current) {
retryTimer = setTimeout(retryInitialization, BASE_DELAY_MS);
return;
}
retryCount += 1;
initializationInFlightRef.current = true;
try {
await state.initializeApp();
} finally {
initializationInFlightRef.current = false;
}
const next = useConfigStore.getState();
if (!active) return;
if (next.isInitialized) {
setInitRetryExhausted(false);
return;
}
if (retryCount >= MAX_RETRIES) {
setInitRetryExhausted(true);
return;
}
const delay = Math.min(BASE_DELAY_MS * Math.pow(2, retryCount - 1), 16000);
retryTimer = setTimeout(retryInitialization, delay);
};
void repair();
}, [agentsCount, isConnected, isVSCodeRuntime, loadAgents, loadProviders, providersCount]);
retryTimer = setTimeout(retryInitialization, BASE_DELAY_MS);
return () => {
active = false;
if (retryTimer) clearTimeout(retryTimer);
};
}, [initRetryEpoch, isInitialized, isVSCodeRuntime]);
React.useEffect(() => {
if (isInitialized) {
setInitRetryExhausted(false);
}
}, [isInitialized]);
React.useEffect(() => {
if (!initRetryExhausted) return;
const loadingElement = document.getElementById('initial-loading');
if (loadingElement) {
loadingElement.classList.add('fade-out');
setTimeout(() => {
loadingElement.remove();
}, 300);
}
}, [initRetryExhausted]);
// Startup recovery: poll until providers AND agents are loaded.
// loadProviders/loadAgents resolve normally even on failure (errors swallowed),
// so a reactive effect can't detect failure — we need an interval.
React.useEffect(() => {
if (isVSCodeRuntime || !isConnected) return;
if (providersCount > 0 && agentsCount > 0) return;
let active = true;
let retries = 0;
const MAX_RETRIES = 15;
const attempt = async () => {
const state = useConfigStore.getState();
if (state.providers.length > 0 && state.agents.length > 0) return;
try {
if (state.providers.length === 0) await loadProviders();
if (useConfigStore.getState().agents.length === 0) await loadAgents();
} catch { /* retry next interval */ }
};
void attempt();
const id = setInterval(() => {
if (!active) return;
if (++retries >= MAX_RETRIES) { clearInterval(id); return; }
void attempt();
}, 2000);
return () => { active = false; clearInterval(id); };
}, [isConnected, isVSCodeRuntime, loadAgents, loadProviders, providersCount, agentsCount]);
React.useEffect(() => {
if (isSwitchingDirectory) {
return;
}
const syncDirectoryAndSessions = async () => {
// VS Code runtime loads sessions via VSCodeLayout bootstrap to avoid startup races.
if (isVSCodeRuntime) {
return;
}
// VS Code runtime loads sessions via VSCodeLayout bootstrap to avoid startup races.
if (isVSCodeRuntime) {
return;
}
if (!isConnected) {
return;
}
opencodeClient.setDirectory(currentDirectory);
if (!isConnected) {
return;
}
opencodeClient.setDirectory(currentDirectory);
await loadSessions();
};
syncDirectoryAndSessions();
}, [currentDirectory, isSwitchingDirectory, loadSessions, isConnected, isVSCodeRuntime]);
// Session loading is handled by the sync system's bootstrap — no manual loadSessions needed.
}, [currentDirectory, isSwitchingDirectory, isConnected, isVSCodeRuntime]);
React.useEffect(() => {
if (!embeddedSessionChat || typeof window === 'undefined') {
@@ -317,22 +559,6 @@ function App({ apis }: AppProps) {
setDirectory(embeddedSessionChat.directory, { showOverlay: false });
}, [currentDirectory, embeddedSessionChat, isVSCodeRuntime, setDirectory]);
React.useEffect(() => {
if (!embeddedSessionChat || isVSCodeRuntime) {
return;
}
if (currentSessionId === embeddedSessionChat.sessionId) {
return;
}
if (!sessions.some((session) => session.id === embeddedSessionChat.sessionId)) {
return;
}
void setCurrentSession(embeddedSessionChat.sessionId);
}, [currentSessionId, embeddedSessionChat, isVSCodeRuntime, sessions, setCurrentSession]);
React.useEffect(() => {
if (!embeddedSessionChat || typeof window === 'undefined') {
return;
@@ -356,6 +582,40 @@ function App({ apis }: AppProps) {
};
}, [embeddedSessionChat]);
React.useEffect(() => {
if (typeof window === 'undefined') return;
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ sessionId?: string }>).detail;
const sessionId = typeof detail?.sessionId === 'string' ? detail.sessionId.trim() : '';
if (!sessionId) return;
void useSessionUIStore.getState().setCurrentSession(sessionId);
};
window.addEventListener('openchamber:open-session', handler as EventListener);
return () => window.removeEventListener('openchamber:open-session', handler as EventListener);
}, []);
React.useEffect(() => {
if (typeof window === 'undefined') return;
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ projectPath?: string }>).detail;
const projectPath = typeof detail?.projectPath === 'string' ? detail.projectPath.trim() : '';
if (!projectPath) return;
const projectsStore = useProjectsStore.getState();
const existing = projectsStore.projects.find((project) => project.path === projectPath);
if (existing) {
projectsStore.setActiveProject(existing.id);
} else {
projectsStore.addProject(projectPath);
}
};
window.addEventListener('openchamber:open-project', handler as EventListener);
return () => window.removeEventListener('openchamber:open-project', handler as EventListener);
}, []);
React.useEffect(() => {
if (typeof window === 'undefined') return;
if (!isInitialized || isSwitchingDirectory) return;
@@ -365,22 +625,17 @@ function App({ apis }: AppProps) {
window.dispatchEvent(new Event('openchamber:app-ready'));
}, [isInitialized, isSwitchingDirectory]);
useEventStream({ enabled: embeddedBackgroundWorkEnabled });
// useEventStream replaced by SyncProvider + SyncBridge
// Server-authoritative session status polling
// Replaces SSE-dependent status updates with reliable HTTP polling
useServerSessionStatus({ enabled: embeddedBackgroundWorkEnabled });
// Session attention now handled by notification-store via SSE events (session.idle/session.error)
usePushVisibilityBeacon({ enabled: embeddedBackgroundWorkEnabled });
usePwaManifestSync();
usePwaInstallPrompt();
useWindowTitle();
useRouter();
useKeyboardShortcuts();
const handleToggleMemoryDebug = React.useCallback(() => {
setShowMemoryDebug(prev => !prev);
}, []);
@@ -388,8 +643,6 @@ function App({ apis }: AppProps) {
useMenuActions(handleToggleMemoryDebug);
useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled });
useSessionAutoCleanup({ enabled: embeddedBackgroundWorkEnabled });
useQueuedMessageAutoSend({ enabled: embeddedBackgroundWorkEnabled });
React.useEffect(() => {
if (embeddedSessionChat) {
@@ -397,14 +650,19 @@ function App({ apis }: AppProps) {
}
const handleKeyDown = (e: KeyboardEvent) => {
if (hasModifier(e) && e.shiftKey && e.key === 'D') {
const isDebugShortcut = hasModifier(e)
&& e.shiftKey
&& !e.altKey
&& (e.code === 'KeyD' || e.key.toLowerCase() === 'd');
if (isDebugShortcut) {
e.preventDefault();
setShowMemoryDebug(prev => !prev);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [embeddedSessionChat]);
React.useEffect(() => {
@@ -418,58 +676,141 @@ function App({ apis }: AppProps) {
}
}, [clearError, embeddedSessionChat, error]);
// Poll for the injected boot outcome until it becomes available (desktop only).
// The Rust backend sets window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ once the
// sidecar reaches a stable state. We poll with exponential backoff to handle
// potential race conditions during startup and config writes.
React.useEffect(() => {
if (embeddedSessionChat) {
return;
}
if (!isDesktopShell() || !isDesktopLocalOriginActive()) {
if (!isDesktopRuntime || bootInjectionStatus !== 'not-injected') {
return;
}
let cancelled = false;
const run = async () => {
const res = await fetch('/health', { method: 'GET' }).catch(() => null);
if (!res || !res.ok || cancelled) return;
const data = (await res.json().catch(() => null)) as null | {
openCodeRunning?: unknown;
isOpenCodeReady?: unknown;
opencodeBinaryResolved?: unknown;
lastOpenCodeError?: unknown;
};
if (!data || cancelled) return;
const openCodeRunning = data.openCodeRunning === true;
const isOpenCodeReady = data.isOpenCodeReady === true;
const resolvedBinary = typeof data.opencodeBinaryResolved === 'string' ? data.opencodeBinaryResolved.trim() : '';
const hasResolvedBinary = resolvedBinary.length > 0;
const err = typeof data.lastOpenCodeError === 'string' ? data.lastOpenCodeError : '';
const cliMissing =
!openCodeRunning &&
(CLI_MISSING_ERROR_REGEX.test(err) || (!hasResolvedBinary && !isOpenCodeReady));
setShowCliOnboarding(cliMissing);
let attempts = 0;
const BASE_INTERVAL = 200;
const MAX_INTERVAL = 2000;
const MAX_ATTEMPTS = 50; // 10 seconds total (200ms * 50 with exponential backoff cap)
const pollWithBackoff = () => {
if (cancelled) return;
attempts++;
const status = getBootInjectionStatus();
if (status !== 'not-injected') {
cancelled = true;
setBootInjectionStatus(status);
if (status === 'valid') {
const outcome = getInjectedBootOutcome();
if (outcome) {
setBootView(resolveDesktopBootView({ isDesktopShell: true, bootOutcome: outcome }));
}
}
// If status is 'malformed', we keep the splash visible with error text
// handled by the separate useEffect below
return;
}
// Exponential backoff with cap
const nextInterval = Math.min(BASE_INTERVAL * Math.pow(1.1, attempts), MAX_INTERVAL);
if (attempts >= MAX_ATTEMPTS) {
// Max attempts reached - keep polling but show error
const loadingElement = document.getElementById('initial-loading');
if (loadingElement && !loadingElement.textContent?.includes('taking longer')) {
loadingElement.textContent = 'Desktop startup is taking longer than expected...';
}
}
window.setTimeout(pollWithBackoff, nextInterval);
};
void run();
const interval = window.setInterval(() => {
void run();
}, CLI_ONBOARDING_HEALTH_POLL_MS);
// Start polling
window.setTimeout(pollWithBackoff, BASE_INTERVAL);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [embeddedSessionChat]);
}, [isDesktopRuntime, bootInjectionStatus]);
const handleDesktopBootDismiss = React.useCallback(async () => {
if (shouldRestartDesktopBootFlow({
isTauriShell: isTauriShell(),
isDesktopLocalOriginActive: isDesktopLocalOriginActive(),
})) {
await restartDesktopApp();
return;
}
const handleCliAvailable = React.useCallback(() => {
setShowCliOnboarding(false);
window.location.reload();
}, []);
if (showCliOnboarding) {
const handleManualInitRetry = React.useCallback(async () => {
if (manualInitRetrying || initializationInFlightRef.current) return;
setInitRetryExhausted(false);
setManualInitRetrying(true);
initializationInFlightRef.current = true;
try {
await useConfigStore.getState().initializeApp();
} finally {
initializationInFlightRef.current = false;
setManualInitRetrying(false);
}
if (!useConfigStore.getState().isInitialized) {
setInitRetryEpoch((value) => value + 1);
}
}, [manualInitRetrying]);
// Map boot outcome kind to recovery variant
const mapBootViewToRecoveryVariant = (view: DesktopBootView): RecoveryVariant | undefined => {
if (view.screen === 'recovery') {
return view.variant;
}
return undefined;
};
// Desktop boot view routing.
// When the boot outcome resolves to a non-main screen (chooser, recovery),
// render OnboardingScreen with appropriate mode/variant.
if (isDesktopRuntime && bootView && bootView.screen !== 'main') {
// First-launch chooser
if (bootView.screen === 'chooser') {
return (
<ErrorBoundary>
<div className="h-full text-foreground bg-transparent">
<React.Suspense fallback={<div className="h-full" />}>
<OnboardingScreen
mode="first-launch"
onCliAvailable={handleDesktopBootDismiss}
onChooseRemote={() => {
// Switch to remote tab - handled internally by OnboardingScreen
}}
/>
</React.Suspense>
</div>
</ErrorBoundary>
);
}
// Recovery screens
const recoveryVariant = mapBootViewToRecoveryVariant(bootView);
const hostUrl = bootView.screen === 'recovery' && 'url' in bootView ? bootView.url : undefined;
return (
<ErrorBoundary>
<div className="h-full text-foreground bg-transparent">
<OnboardingScreen onCliAvailable={handleCliAvailable} />
<React.Suspense fallback={<div className="h-full" />}>
<OnboardingScreen
mode="recovery"
recoveryVariant={recoveryVariant}
recoveryHostUrl={hostUrl}
recoveryHostLabel={undefined}
onCliAvailable={handleDesktopBootDismiss}
/>
</React.Suspense>
</div>
</ErrorBoundary>
);
@@ -478,14 +819,37 @@ function App({ apis }: AppProps) {
if (embeddedSessionChat) {
return (
<ErrorBoundary>
<RuntimeAPIProvider apis={apis}>
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
<div className="h-full text-foreground bg-background">
<ChatView />
<Toaster />
</div>
</TooltipProvider>
</RuntimeAPIProvider>
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
<RuntimeAPIProvider apis={apis}>
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
<div className="h-full text-foreground bg-background">
<EmbeddedSessionSelectionGate embeddedSessionChat={embeddedSessionChat} isVSCodeRuntime={isVSCodeRuntime} />
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
<ChatView />
<Toaster />
</div>
</TooltipProvider>
</RuntimeAPIProvider>
</SyncProvider>
</ErrorBoundary>
);
}
if (isMcpOAuthCallback) {
return (
<ErrorBoundary>
<McpOAuthCallbackPage />
</ErrorBoundary>
);
}
if (initRetryExhausted && !isInitialized && !isVSCodeRuntime && !embeddedSessionChat) {
return (
<ErrorBoundary>
<StartupInitializationRecovery
onRetry={() => { void handleManualInitRetry(); }}
isRetrying={manualInitRetrying}
/>
</ErrorBoundary>
);
}
@@ -493,62 +857,79 @@ function App({ apis }: AppProps) {
// VS Code runtime - simplified layout without git/terminal views
if (isVSCodeRuntime) {
// Check if this is the Agent Manager panel
const panelType = typeof window !== 'undefined'
? (window as { __OPENCHAMBER_PANEL_TYPE__?: 'chat' | 'agentManager' }).__OPENCHAMBER_PANEL_TYPE__
const panelType = typeof window !== 'undefined'
? (window as { __OPENCHAMBER_PANEL_TYPE__?: 'chat' | 'agentManager' }).__OPENCHAMBER_PANEL_TYPE__
: 'chat';
if (panelType === 'agentManager') {
return (
<ErrorBoundary>
<RuntimeAPIProvider apis={apis}>
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
<div className="h-full text-foreground bg-background">
<AgentManagerView />
<Toaster />
</div>
</TooltipProvider>
</RuntimeAPIProvider>
</ErrorBoundary>
);
}
return (
<ErrorBoundary>
<RuntimeAPIProvider apis={apis}>
<FireworksProvider>
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
<RuntimeAPIProvider apis={apis}>
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
<div className="h-full text-foreground bg-background">
<VSCodeLayout />
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
<AgentManagerView />
<Toaster />
</div>
</TooltipProvider>
</FireworksProvider>
</RuntimeAPIProvider>
</RuntimeAPIProvider>
</SyncProvider>
</ErrorBoundary>
);
}
return (
<ErrorBoundary>
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
<RuntimeAPIProvider apis={apis}>
<FireworksProvider>
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
<div className="h-full text-foreground bg-background">
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
<VSCodeLayout />
<Toaster />
</div>
</TooltipProvider>
</FireworksProvider>
</RuntimeAPIProvider>
</SyncProvider>
</ErrorBoundary>
);
}
// Always mount the full provider tree to avoid remounts when isInitialized
// flips from false → true. FireworksProvider and VoiceProvider are lightweight
// shells; their heavy children are only activated when actually needed.
const isBootShell = !isInitialized && !isDesktopRuntime;
return (
<ErrorBoundary>
<RuntimeAPIProvider apis={apis}>
<GitPollingProvider>
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
<RuntimeAPIProvider apis={apis}>
<FireworksProvider>
<VoiceProvider>
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
<div className={isDesktopRuntime ? 'h-full text-foreground bg-transparent' : 'h-full text-foreground bg-background'}>
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
<MainLayout />
<Toaster />
<ConfigUpdateOverlay />
<AboutDialogWrapper />
{showMemoryDebug && (
<MemoryDebugPanel onClose={() => setShowMemoryDebug(false)} />
{!isBootShell && (
<>
<ConfigUpdateOverlay />
<QuickOpenDialog />
<AboutDialogWrapper />
{showMemoryDebug && (
<MemoryDebugPanel onClose={() => setShowMemoryDebug(false)} />
)}
</>
)}
</div>
</TooltipProvider>
</VoiceProvider>
</FireworksProvider>
</GitPollingProvider>
</RuntimeAPIProvider>
</RuntimeAPIProvider>
</SyncProvider>
</ErrorBoundary>
);
}
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="#f9a825" d="M560-160v-80h120q17 0 28.5-11.5T720-280v-80q0-38 22-69t58-44v-14q-36-13-58-44t-22-69v-80q0-17-11.5-28.5T680-720H560v-80h120q50 0 85 35t35 85v80q0 17 11.5 28.5T840-560h40v160h-40q-17 0-28.5 11.5T800-360v80q0 50-35 85t-85 35zm-280 0q-50 0-85-35t-35-85v-80q0-17-11.5-28.5T120-400H80v-160h40q17 0 28.5-11.5T160-600v-80q0-50 35-85t85-35h120v80H280q-17 0-28.5 11.5T240-680v80q0 38-22 69t-58 44v14q36 13 58 44t22 69v80q0 17 11.5 28.5T280-240h120v80z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 960"><path fill="#f9a825" transform="translate(0 960)" d="M560-160v-80h120q17 0 28.5-11.5T720-280v-80q0-38 22-69t58-44v-14q-36-13-58-44t-22-69v-80q0-17-11.5-28.5T680-720H560v-80h120q50 0 85 35t35 85v80q0 17 11.5 28.5T840-560h40v160h-40q-17 0-28.5 11.5T800-360v80q0 50-35 85t-85 35zm-280 0q-50 0-85-35t-35-85v-80q0-17-11.5-28.5T120-400H80v-160h40q17 0 28.5-11.5T160-600v-80q0-50 35-85t85-35h120v80H280q-17 0-28.5 11.5T240-680v80q0 38-22 69t-58 44v14q36 13 58 44t22 69v80q0 17 11.5 28.5T280-240h120v80z"/></svg>

Before

Width:  |  Height:  |  Size: 540 B

After

Width:  |  Height:  |  Size: 567 B

@@ -4533,10 +4533,10 @@
<symbol id="jsconfig_light" viewBox="0 0 32 32">
<use href="#jsconfig" />
</symbol>
<symbol id="json" viewBox="0 -960 960 960">
<path fill="#f9a825" d="M560-160v-80h120q17 0 28.5-11.5T720-280v-80q0-38 22-69t58-44v-14q-36-13-58-44t-22-69v-80q0-17-11.5-28.5T680-720H560v-80h120q50 0 85 35t35 85v80q0 17 11.5 28.5T840-560h40v160h-40q-17 0-28.5 11.5T800-360v80q0 50-35 85t-85 35zm-280 0q-50 0-85-35t-35-85v-80q0-17-11.5-28.5T120-400H80v-160h40q17 0 28.5-11.5T160-600v-80q0-50 35-85t85-35h120v80H280q-17 0-28.5 11.5T240-680v80q0 38-22 69t-58 44v14q36 13 58 44t22 69v80q0 17 11.5 28.5T280-240h120v80z"/>
<symbol id="json" viewBox="0 0 960 960">
<path fill="#f9a825" transform="translate(0 960)" d="M560-160v-80h120q17 0 28.5-11.5T720-280v-80q0-38 22-69t58-44v-14q-36-13-58-44t-22-69v-80q0-17-11.5-28.5T680-720H560v-80h120q50 0 85 35t35 85v80q0 17 11.5 28.5T840-560h40v160h-40q-17 0-28.5 11.5T800-360v80q0 50-35 85t-85 35zm-280 0q-50 0-85-35t-35-85v-80q0-17-11.5-28.5T120-400H80v-160h40q17 0 28.5-11.5T160-600v-80q0-50 35-85t85-35h120v80H280q-17 0-28.5 11.5T240-680v80q0 38-22 69t-58 44v14q36 13 58 44t22 69v80q0 17 11.5 28.5T280-240h120v80z"/>
</symbol>
<symbol id="json_light" viewBox="0 -960 960 960">
<symbol id="json_light" viewBox="0 0 960 960">
<use href="#json" />
</symbol>
<symbol id="jsr" viewBox="0 0 16 16">

Before

Width:  |  Height:  |  Size: 943 KiB

After

Width:  |  Height:  |  Size: 943 KiB

@@ -1,14 +1,28 @@
import React from 'react';
import { RiLockLine, RiLockUnlockLine, RiLoader4Line } from '@remixicon/react';
import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui';
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { syncDesktopSettings, initializeAppearancePreferences } from '@/lib/persistence';
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
import { DesktopHostSwitcherInline } from '@/components/desktop/DesktopHostSwitcher';
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { useI18n } from '@/lib/i18n';
import {
authenticateWithPasskey,
cancelPasskeyCeremony,
defaultPasskeyStatus,
fetchPasskeyStatus,
isPasskeyCeremonyAbort,
type PasskeyStatus,
registerCurrentDevicePasskey,
} from '@/lib/passkeys';
const STATUS_CHECK_ENDPOINT = '/auth/session';
const TRUST_DEVICE_STORAGE_KEY = 'openchamber.uiAuth.trustDevice';
const fetchSessionStatus = async (): Promise<Response> => {
console.log('[Frontend Auth] Checking session status...');
@@ -23,7 +37,14 @@ const fetchSessionStatus = async (): Promise<Response> => {
return response;
};
const submitPassword = async (password: string): Promise<Response> => {
const readStoredTrustDevice = (): boolean => {
if (typeof window === 'undefined') {
return false;
}
return window.localStorage.getItem(TRUST_DEVICE_STORAGE_KEY) === 'true';
};
const submitPassword = async (password: string, trustDevice: boolean): Promise<Response> => {
console.log('[Frontend Auth] Submitting password...');
const response = await fetch(STATUS_CHECK_ENDPOINT, {
method: 'POST',
@@ -32,7 +53,7 @@ const submitPassword = async (password: string): Promise<Response> => {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ password }),
body: JSON.stringify({ password, trustDevice }),
});
console.log('[Frontend Auth] Password submit response:', response.status, response.statusText);
return response;
@@ -64,11 +85,12 @@ const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => (
const LoadingScreen: React.FC = () => (
<div className="flex min-h-screen items-center justify-center bg-background text-foreground">
<OpenChamberLogo width={120} height={120} isAnimated />
<OpenChamberLogo width={120} height={120} />
</div>
);
const ErrorScreen: React.FC<ErrorScreenProps> = ({ onRetry, errorType = 'network', retryAfter }) => {
const { t } = useI18n();
const isRateLimit = errorType === 'rate-limit';
const minutes = retryAfter ? Math.ceil(retryAfter / 60) : 1;
@@ -77,16 +99,18 @@ const ErrorScreen: React.FC<ErrorScreenProps> = ({ onRetry, errorType = 'network
<div className="flex flex-col items-center gap-6 text-center">
<div className="space-y-2">
<h1 className="typography-ui-header font-semibold text-destructive">
{isRateLimit ? 'Too many attempts' : 'Unable to reach server'}
{isRateLimit ? t('sessionAuth.error.rateLimitTitle') : t('sessionAuth.error.networkTitle')}
</h1>
<p className="typography-meta text-muted-foreground max-w-xs">
{isRateLimit
? `Please wait ${minutes} minute${minutes > 1 ? 's' : ''} before trying again.`
: "We couldn't verify the UI session. Check that the service is running and try again."}
? (minutes > 1
? t('sessionAuth.error.rateLimitDescriptionPlural', { minutes })
: t('sessionAuth.error.rateLimitDescriptionSingle', { minutes }))
: t('sessionAuth.error.networkDescription')}
</p>
</div>
<Button type="button" onClick={onRetry} className="w-full max-w-xs">
Retry
{t('sessionAuth.error.retry')}
</Button>
</div>
</AuthShell>
@@ -106,6 +130,7 @@ interface ErrorScreenProps {
}
export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) => {
const { t } = useI18n();
const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []);
const skipAuth = vscodeRuntime;
const showHostSwitcher = React.useMemo(() => isDesktopShell() && !vscodeRuntime, [vscodeRuntime]);
@@ -115,9 +140,66 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
const [errorMessage, setErrorMessage] = React.useState('');
const [retryAfter, setRetryAfter] = React.useState<number | undefined>(undefined);
const [isTunnelLocked, setIsTunnelLocked] = React.useState(false);
const [passkeyStatus, setPasskeyStatus] = React.useState<PasskeyStatus>(defaultPasskeyStatus);
const [supportsPasskeys, setSupportsPasskeys] = React.useState(false);
const [isPasskeyBusy, setIsPasskeyBusy] = React.useState(false);
const [trustDevice, setTrustDevice] = React.useState<boolean>(() => readStoredTrustDevice());
const [activePasskeyAction, setActivePasskeyAction] = React.useState<'auth' | 'register' | null>(null);
const passwordInputRef = React.useRef<HTMLInputElement | null>(null);
const hasResyncedRef = React.useRef(skipAuth);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(TRUST_DEVICE_STORAGE_KEY, trustDevice ? 'true' : 'false');
}, [trustDevice]);
const refreshPasskeyStatus = React.useCallback(async () => {
if (skipAuth) {
return defaultPasskeyStatus;
}
try {
const nextStatus = await fetchPasskeyStatus();
setPasskeyStatus(nextStatus);
return nextStatus;
} catch {
setPasskeyStatus(defaultPasskeyStatus);
return defaultPasskeyStatus;
}
}, [skipAuth]);
React.useEffect(() => {
let cancelled = false;
if (skipAuth) {
return;
}
void (async () => {
try {
if (!window.isSecureContext || !browserSupportsWebAuthn()) {
if (!cancelled) {
setSupportsPasskeys(false);
}
return;
}
if (!cancelled) {
setSupportsPasskeys(true);
}
} catch {
if (!cancelled) {
setSupportsPasskeys(false);
}
}
})();
return () => {
cancelled = true;
};
}, [skipAuth]);
const checkStatus = React.useCallback(async () => {
if (skipAuth) {
console.log('[Frontend Auth] VSCode runtime, skipping auth');
@@ -125,16 +207,12 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
return;
}
// 检查 cookie 是否存在
const cookies = document.cookie;
const hasAccessToken = cookies.includes('oc_ui_session=');
const hasRefreshToken = cookies.includes('oc_ui_refresh=');
console.log('[Frontend Auth] Cookies check - access:', hasAccessToken, 'refresh:', hasRefreshToken);
console.log('[Frontend Auth] All cookies:', cookies.split(';').map(c => c.trim().split('=')[0]));
setState((prev) => (prev === 'authenticated' ? prev : 'pending'));
try {
const response = await fetchSessionStatus();
const [response, latestPasskeyStatus] = await Promise.all([
fetchSessionStatus(),
refreshPasskeyStatus(),
]);
const responseText = await response.text();
console.log('[Frontend Auth] Raw response:', response.status, responseText);
@@ -158,6 +236,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
console.warn('[Frontend Auth] Debug info:', data.debug);
}
setIsTunnelLocked(data.tunnelLocked === true);
setPasskeyStatus(latestPasskeyStatus);
setState('locked');
setRetryAfter(undefined);
return;
@@ -182,7 +261,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
setState('error');
setIsTunnelLocked(false);
}
}, [skipAuth]);
}, [refreshPasskeyStatus, skipAuth]);
React.useEffect(() => {
if (skipAuth) {
@@ -220,6 +299,28 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
await handlePasswordUnlock(false);
};
const registerPasskeyForCurrentSession = React.useCallback(async () => {
setActivePasskeyAction('register');
setIsPasskeyBusy(true);
try {
await registerCurrentDevicePasskey();
} finally {
setActivePasskeyAction(null);
setIsPasskeyBusy(false);
}
await refreshPasskeyStatus();
}, [refreshPasskeyStatus]);
const cancelActivePasskey = React.useCallback(() => {
cancelPasskeyCeremony();
setActivePasskeyAction(null);
setIsPasskeyBusy(false);
}, []);
const handlePasswordUnlock = React.useCallback(async (enrollPasskey: boolean) => {
if (isTunnelLocked) {
return;
}
@@ -227,28 +328,43 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
return;
}
if (isPasskeyBusy) {
cancelActivePasskey();
}
setIsSubmitting(true);
setErrorMessage('');
try {
const response = await submitPassword(password);
const response = await submitPassword(password, trustDevice);
if (response.ok) {
console.log('[Frontend Auth] Login successful');
// 检查登录后 cookie 是否被设置
const cookies = document.cookie;
const hasAccessToken = cookies.includes('oc_ui_session=');
const hasRefreshToken = cookies.includes('oc_ui_refresh=');
console.log('[Frontend Auth] After login - access:', hasAccessToken, 'refresh:', hasRefreshToken);
console.log('[Frontend Auth] All cookies after login:', cookies.split(';').map(c => c.trim().split('=')[0]).filter(Boolean));
setPassword('');
setIsTunnelLocked(false);
if (enrollPasskey && supportsPasskeys) {
try {
await registerPasskeyForCurrentSession();
toast.success(t('sessionAuth.toast.passkeyAdded'));
setState('authenticated');
return;
} catch (error) {
if (isPasskeyCeremonyAbort(error)) {
toast.message(t('sessionAuth.toast.passkeySetupCanceled'));
} else {
const message = error instanceof Error ? error.message : t('sessionAuth.error.passkeySetupFailed');
toast.error(message);
}
setState('authenticated');
return;
}
}
setState('authenticated');
return;
}
if (response.status === 401) {
console.warn('[Frontend Auth] Login failed: Invalid password');
setErrorMessage('Incorrect password. Try again.');
setErrorMessage(t('sessionAuth.error.incorrectPassword'));
setIsTunnelLocked(false);
setState('locked');
return;
@@ -264,18 +380,86 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
}
console.error('[Frontend Auth] Login failed: Unexpected response', response.status);
setErrorMessage('Unexpected response from server.');
setErrorMessage(t('sessionAuth.error.unexpectedResponse'));
setIsTunnelLocked(false);
setState('error');
} catch (error) {
console.warn('Failed to submit UI password:', error);
setErrorMessage('Network error. Check connection and retry.');
setErrorMessage(t('sessionAuth.error.networkRetry'));
setIsTunnelLocked(false);
setState('error');
} finally {
setIsSubmitting(false);
}
};
}, [cancelActivePasskey, isPasskeyBusy, isSubmitting, isTunnelLocked, password, registerPasskeyForCurrentSession, supportsPasskeys, t, trustDevice]);
const handlePasskeyUnlock = React.useCallback(async () => {
if (isSubmitting || !supportsPasskeys) {
return;
}
if (isPasskeyBusy) {
cancelActivePasskey();
return;
}
setIsPasskeyBusy(true);
setActivePasskeyAction('auth');
setErrorMessage('');
try {
await authenticateWithPasskey(trustDevice);
setPassword('');
setState('authenticated');
} catch (error) {
if (isPasskeyCeremonyAbort(error)) {
setErrorMessage('');
} else {
const message = error instanceof Error ? error.message : t('sessionAuth.error.passkeySignInCanceled');
setErrorMessage(message);
}
} finally {
setActivePasskeyAction(null);
setIsPasskeyBusy(false);
}
}, [cancelActivePasskey, isPasskeyBusy, isSubmitting, supportsPasskeys, t, trustDevice]);
const handlePasskeySetupOnly = React.useCallback(async () => {
if (isSubmitting || isTunnelLocked || !supportsPasskeys) {
return;
}
if (isPasskeyBusy) {
cancelActivePasskey();
return;
}
if (state !== 'authenticated') {
if (!password) {
setErrorMessage(t('sessionAuth.error.enterPasswordForPasskey'));
return;
}
await handlePasswordUnlock(true);
return;
}
setErrorMessage('');
try {
await registerPasskeyForCurrentSession();
toast.success(t('sessionAuth.toast.passkeyAdded'));
} catch (error) {
if (isPasskeyCeremonyAbort(error)) {
toast.message(t('sessionAuth.toast.passkeySetupCanceled'));
return;
}
const message = error instanceof Error ? error.message : t('sessionAuth.error.passkeySetupFailed');
toast.error(message);
}
}, [cancelActivePasskey, handlePasswordUnlock, isPasskeyBusy, isSubmitting, isTunnelLocked, password, registerPasskeyForCurrentSession, state, supportsPasskeys, t]);
const canOfferPasskeySetup = supportsPasskeys && passkeyStatus.enabled;
const canUsePasskey = canOfferPasskeySetup && passkeyStatus.hasPasskeys;
if (state === 'pending') {
return <LoadingScreen />;
@@ -295,17 +479,35 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
<div className="flex flex-col items-center gap-6 w-full max-w-xs">
<div className="flex flex-col items-center gap-1 text-center">
<h1 className="text-xl font-semibold text-foreground">
{isTunnelLocked ? 'Tunnel access required' : 'Unlock OpenChamber'}
{isTunnelLocked ? t('sessionAuth.locked.tunnelTitle') : t('sessionAuth.locked.unlockTitle')}
</h1>
<p className="typography-meta text-muted-foreground">
{isTunnelLocked
? 'Open this tunnel using the one-time connect link from the desktop app.'
: 'This session is password-protected.'}
? t('sessionAuth.locked.tunnelDescription')
: t('sessionAuth.locked.passwordDescription')}
</p>
</div>
{!isTunnelLocked && (
<form onSubmit={handleSubmit} className="w-full space-y-2" data-keyboard-avoid="true">
<form onSubmit={handleSubmit} className="w-full space-y-2">
{canUsePasskey && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => void handlePasskeyUnlock()}
disabled={isSubmitting || (isPasskeyBusy && activePasskeyAction !== 'auth')}
>
{isPasskeyBusy ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiLockUnlockLine className="h-4 w-4" />
)}
<span>{isPasskeyBusy && activePasskeyAction === 'auth'
? t('sessionAuth.actions.cancelPasskey')
: t('sessionAuth.actions.usePasskey')}</span>
</Button>
)}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<RiLockLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/60" />
@@ -314,7 +516,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
ref={passwordInputRef}
type="password"
autoComplete="current-password"
placeholder="Enter password"
placeholder={t('sessionAuth.password.placeholder')}
value={password}
onChange={(event) => {
setPassword(event.target.value);
@@ -332,7 +534,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
type="submit"
size="icon"
disabled={!password || isSubmitting}
aria-label={isSubmitting ? 'Unlocking' : 'Unlock'}
aria-label={isSubmitting ? t('sessionAuth.actions.unlockingAria') : t('sessionAuth.actions.unlockAria')}
>
{isSubmitting ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
@@ -341,6 +543,45 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
)}
</Button>
</div>
{canOfferPasskeySetup ? (
<div className="flex items-center justify-between pt-1">
<label className="flex items-center gap-2 text-center typography-micro text-muted-foreground">
<Checkbox
checked={trustDevice}
onChange={setTrustDevice}
disabled={isSubmitting}
ariaLabel={t('sessionAuth.actions.trustDeviceAria')}
className="size-4"
iconClassName="size-4"
/>
<span>{t('sessionAuth.actions.trustDevice')}</span>
</label>
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => void handlePasskeySetupOnly()}
disabled={isSubmitting}
>
{isPasskeyBusy && activePasskeyAction === 'register'
? t('sessionAuth.actions.cancelPasskeySetup')
: t('sessionAuth.actions.addPasskey')}
</Button>
</div>
) : (
<label className="flex items-center justify-center gap-2 pt-1 text-center typography-micro text-muted-foreground">
<Checkbox
checked={trustDevice}
onChange={setTrustDevice}
disabled={isSubmitting}
ariaLabel={t('sessionAuth.actions.trustDeviceAria')}
className="size-4"
iconClassName="size-4"
/>
<span>{t('sessionAuth.actions.trustDevice')}</span>
</label>
)}
{errorMessage && (
<p id="oc-ui-auth-error" className="typography-meta text-destructive">
{errorMessage}
@@ -353,7 +594,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
<div className="w-full">
<DesktopHostSwitcherInline />
<p className="mt-1 text-center typography-micro text-muted-foreground">
Use Local if remote is unreachable.
{t('sessionAuth.locked.hostSwitcherHint')}
</p>
</div>
)}
@@ -3,6 +3,7 @@ import { cn, fuzzyMatch } from '@/lib/utils';
import { useConfigStore } from '@/stores/useConfigStore';
import { useAgentsStore, isAgentBuiltIn, type AgentWithExtras } from '@/stores/useAgentsStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useI18n } from '@/lib/i18n';
interface AgentInfo {
name: string;
@@ -40,13 +41,15 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
activeTab = 'agents',
onTabSelect,
}, ref) => {
const { t } = useI18n();
const containerRef = React.useRef<HTMLDivElement | null>(null);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const ignoreTabClickRef = React.useRef(false);
const { getVisibleAgents } = useConfigStore();
const { agents: agentsWithMetadata, loadAgents } = useAgentsStore();
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
const agentsWithMetadata = useAgentsStore((state) => state.agents);
const loadAgents = useAgentsStore((state) => state.loadAgents);
React.useEffect(() => {
if (agentsWithMetadata.length === 0) {
@@ -156,7 +159,7 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
<span className="font-semibold">#{agent.name}</span>
{isSystem ? (
<span className="text-[10px] leading-none uppercase font-bold tracking-tight bg-[var(--status-warning-background)] text-[var(--status-warning)] border-[var(--status-warning-border)] px-1.5 py-1 rounded border flex-shrink-0">
system
{t('chat.agentMentionAutocomplete.badge.system')}
</span>
) : agent.scope ? (
<span className={cn(
@@ -179,6 +182,12 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
);
};
const tabs = React.useMemo(() => ([
{ id: 'commands' as const, label: t('chat.autocomplete.tabs.commands') },
{ id: 'agents' as const, label: t('chat.autocomplete.tabs.agents') },
{ id: 'files' as const, label: t('chat.autocomplete.tabs.files') },
]), [t]);
return (
<div
ref={containerRef}
@@ -187,11 +196,7 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
{showTabs ? (
<div className="px-2 pt-2 pb-1 border-b border-border/60">
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-elevated)] p-1">
{([
{ id: 'commands' as const, label: 'Commands' },
{ id: 'agents' as const, label: 'Agents' },
{ id: 'files' as const, label: 'Files' },
]).map((tab) => (
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
@@ -231,12 +236,12 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
</div>
) : (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
No agents found
{t('chat.agentMentionAutocomplete.empty')}
</div>
)}
</ScrollableOverlay>
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
navigate Enter select Esc close
{t('chat.autocomplete.keyboardHint')}
</div>
</div>
);
@@ -0,0 +1,65 @@
import React from 'react';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { type ChangedFileEntry, getDisplayPath, getFileStats } from './changedFiles';
import { useI18n } from '@/lib/i18n';
interface ChangedFilesListProps {
files: ChangedFileEntry[];
currentDirectory: string;
onOpenFile: (file: ChangedFileEntry) => void;
}
export const ChangedFilesList: React.FC<ChangedFilesListProps> = ({ files, currentDirectory, onOpenFile }) => {
const { t } = useI18n();
return (
<>
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
<span>{t('chat.changedFiles.title')}</span>
<span className="typography-meta tabular-nums">{files.length}</span>
</div>
<div className="max-h-[260px] overflow-y-auto">
{files.map((file, index) => {
const { fileName, dirPart } = getDisplayPath(file, currentDirectory);
const stats = getFileStats(file);
return (
<button
key={`${file.path}:${index}`}
type="button"
className="relative flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1 typography-ui-label outline-hidden select-none text-left hover:bg-interactive-hover"
title={t('chat.changedFiles.actions.openFileTitle', { path: file.path })}
onClick={() => onOpenFile(file)}
>
<FileTypeIcon filePath={file.path} className="h-3.5 w-3.5 flex-shrink-0" />
<span className="min-w-0 flex-1 flex items-baseline overflow-hidden" title={file.path}>
{dirPart ? (
<>
<span
className="min-w-0 truncate text-muted-foreground"
style={{ direction: 'rtl', textAlign: 'left' }}
>
{dirPart}
</span>
<span className="flex-shrink-0">
<span className="text-muted-foreground">/</span>
<span className="text-foreground">{fileName}</span>
</span>
</>
) : (
<span className="truncate text-foreground">{fileName}</span>
)}
</span>
{(stats.additions > 0 || stats.deletions > 0) ? (
<span className="flex-shrink-0 inline-flex items-baseline gap-1 text-[0.75rem] tabular-nums">
{stats.additions > 0 ? <span style={{ color: 'var(--status-success)' }}>+{stats.additions}</span> : null}
{stats.deletions > 0 ? <span style={{ color: 'var(--status-error)' }}>-{stats.deletions}</span> : null}
</span>
) : null}
</button>
);
})}
</div>
</>
);
};
File diff suppressed because it is too large Load Diff
@@ -1,45 +1,29 @@
import React from 'react';
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { TextLoop } from '@/components/ui/TextLoop';
import { useThemeSystem } from '@/contexts/useThemeSystem';
const phrases = [
"Fix the failing tests",
"Refactor this to be more readable",
"Add form validation",
"Optimize this function",
"Write tests for this",
"Explain how this works",
"Add a new feature",
"Help me debug this",
"Review my code",
"Simplify this logic",
"Add error handling",
"Create a new component",
"Update the documentation",
"Find the bug here",
"Improve performance",
"Add type definitions",
];
import { useGlobalSyncStore } from '@/sync/global-sync-store';
import { useI18n } from '@/lib/i18n';
const ChatEmptyState: React.FC = () => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const initError = useGlobalSyncStore((s) => s.error);
// Use theme's muted foreground for secondary text
const textColor = currentTheme?.colors?.surface?.mutedForeground || 'var(--muted-foreground)';
return (
<div className="flex flex-col items-center justify-center min-h-full w-full gap-6">
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
<TextLoop
className="text-body-md"
interval={4}
transition={{ duration: 0.5 }}
>
{phrases.map((phrase) => (
<span key={phrase} style={{ color: textColor }}>"{phrase}…"</span>
))}
</TextLoop>
<OpenChamberLogo width={140} height={140} className="opacity-20" />
{initError ? (
<div className="flex flex-col items-center gap-2 max-w-md text-center px-4">
<span className="text-body-md font-medium text-destructive">{t('chat.emptyState.opencodeUnreachable')}</span>
<span className="text-body-sm" style={{ color: textColor }}>
{initError.message}
</span>
</div>
) : (
<span className="text-body-md" style={{ color: textColor }}>{t('chat.emptyState.startNewChat')}</span>
)}
</div>
);
};
@@ -2,6 +2,7 @@ import React from 'react';
import { RiChat3Line, RiRestartLine } from '@remixicon/react';
import { Button } from '../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { useI18n } from '@/lib/i18n';
interface ChatErrorBoundaryState {
hasError: boolean;
@@ -14,8 +15,21 @@ interface ChatErrorBoundaryProps {
sessionId?: string;
}
export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, ChatErrorBoundaryState> {
constructor(props: ChatErrorBoundaryProps) {
interface ChatErrorBoundaryTexts {
title: string;
description: string;
sessionLabel: string;
detailsSummary: string;
resetAction: string;
persistentHint: string;
}
interface ChatErrorBoundaryViewProps extends ChatErrorBoundaryProps {
texts: ChatErrorBoundaryTexts;
}
class ChatErrorBoundaryView extends React.Component<ChatErrorBoundaryViewProps, ChatErrorBoundaryState> {
constructor(props: ChatErrorBoundaryViewProps) {
super(props);
this.state = { hasError: false };
}
@@ -44,24 +58,24 @@ export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, C
<CardHeader className="text-center">
<CardTitle className="flex items-center justify-center gap-2 text-destructive">
<RiChat3Line className="h-5 w-5" />
Chat Error
{this.props.texts.title}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground text-center">
The chat interface encountered an error. This might be due to a temporary network issue or corrupted message data.
{this.props.texts.description}
</p>
{this.props.sessionId && (
<div className="text-xs text-muted-foreground text-center">
Session: {this.props.sessionId}
{this.props.texts.sessionLabel}: {this.props.sessionId}
</div>
)}
{this.state.error && (
<details className="text-xs font-mono bg-muted p-3 rounded">
<summary className="cursor-pointer hover:bg-interactive-hover/80">Error details</summary>
<pre className="mt-2 overflow-x-auto">
<summary className="cursor-pointer hover:bg-interactive-hover/80">{this.props.texts.detailsSummary}</summary>
<pre className="mt-2 max-h-48 overflow-auto">
{this.state.error.toString()}
</pre>
</details>
@@ -70,12 +84,12 @@ export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, C
<div className="flex gap-2">
<Button onClick={this.handleReset} variant="outline" className="flex-1">
<RiRestartLine className="h-4 w-4 mr-2" />
Reset Chat
{this.props.texts.resetAction}
</Button>
</div>
<div className="text-xs text-muted-foreground text-center">
If the problem persists, try refreshing the page.
{this.props.texts.persistentHint}
</div>
</CardContent>
</Card>
@@ -86,3 +100,20 @@ export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, C
return this.props.children;
}
}
export function ChatErrorBoundary(props: ChatErrorBoundaryProps) {
const { t } = useI18n();
return (
<ChatErrorBoundaryView
{...props}
texts={{
title: t('chat.errorBoundary.title'),
description: t('chat.errorBoundary.description'),
sessionLabel: t('chat.errorBoundary.sessionLabel'),
detailsSummary: t('chat.errorBoundary.detailsSummary'),
resetAction: t('chat.errorBoundary.resetAction'),
persistentHint: t('chat.errorBoundary.persistentHint'),
}}
/>
);
}
File diff suppressed because it is too large Load Diff
@@ -4,10 +4,13 @@ import { useShallow } from 'zustand/react/shallow';
import { defaultCodeDark, defaultCodeLight } from '@/lib/codeTheme';
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useContextStore } from '@/stores/contextStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import * as sessionActions from '@/sync/session-actions';
import { useDeviceInfo } from '@/lib/device';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
@@ -23,11 +26,14 @@ import { filterVisibleParts } from './message/partUtils';
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import type { TurnGroupingContext } from './lib/turns/types';
import { copyTextToClipboard } from '@/lib/clipboard';
import { FadeInOnReveal } from './message/FadeInOnReveal';
import { streamPerfCount } from '@/stores/utils/streamDebug';
import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual } from './message/renderCompare';
const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog'));
const ToolOutputDialog = lazyWithChunkRecovery(() => import('./message/ToolOutputDialog'));
const EXPANDED_TOOLS_CACHE_MAX = 4000;
const expandedToolsStateCache = new Map<string, Set<string>>();
@@ -123,6 +129,9 @@ interface ChatMessageProps {
animationHandlers?: AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
turnGroupingContext?: TurnGroupingContext;
assistantHeaderMessageId?: string;
isInActiveTurn?: boolean;
activeStreamingPhase?: StreamPhase | null;
animateUserOnMount?: boolean;
onUserAnimationConsumed?: (messageId: string) => void;
}
@@ -134,6 +143,9 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
onContentChange,
animationHandlers,
turnGroupingContext,
assistantHeaderMessageId,
isInActiveTurn = false,
activeStreamingPhase = null,
animateUserOnMount = false,
onUserAnimationConsumed,
}) => {
@@ -141,36 +153,19 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const { currentTheme } = useThemeSystem();
const messageContainerRef = React.useRef<HTMLDivElement | null>(null);
const sessionState = useSessionStore(
useShallow((state) => ({
lifecyclePhase: state.messageStreamStates.get(message.info.id)?.phase ?? null,
isStreamingMessage: (() => {
const sessionId =
(message.info as { sessionID?: string }).sessionID ??
state.currentSessionId ??
null;
if (!sessionId) return false;
return (state.streamingMessageIds.get(sessionId) ?? null) === message.info.id;
})(),
currentSessionId: state.currentSessionId,
getAgentModelForSession: state.getAgentModelForSession,
getSessionModelSelection: state.getSessionModelSelection,
revertToMessage: state.revertToMessage,
forkFromMessage: state.forkFromMessage,
}))
);
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const {
lifecyclePhase,
isStreamingMessage,
currentSessionId,
getAgentModelForSession,
getSessionModelSelection,
revertToMessage,
forkFromMessage,
} = sessionState;
const getAgentModelForSession = useSelectionStore((s) => s.getAgentModelForSession);
const getSessionModelSelection = useSelectionStore((s) => s.getSessionModelSelection);
const revertToMessage = sessionActions.revertToMessage;
const forkFromMessage = sessionActions.forkFromMessage;
const providers = useConfigStore((state) => state.providers);
streamPerfCount('ui.chat_message.render');
if (isInActiveTurn) {
streamPerfCount('ui.chat_message.render.streaming');
}
const providers = useConfigStore.getState().providers;
const { showReasoningTraces, stickyUserHeader, chatRenderMode, showExpandedBashTools, showExpandedEditTools } = useUIStore(
useShallow((state) => ({
showReasoningTraces: state.showReasoningTraces,
@@ -210,12 +205,13 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const showStickyInlineHoverRow = isUser && !isMobile && stickyUserHeader && !useExternalUserActionsRow;
const sessionId = message.info.sessionID;
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
// Subscribe to context changes so badges update immediately on mode switches.
// Keep non-active-turn rows detached from context-store churn.
const { currentContextAgent, savedSessionAgentSelection } = useContextStore(
useShallow((state) => ({
currentContextAgent: sessionId ? state.currentAgentContext.get(sessionId) : undefined,
savedSessionAgentSelection: sessionId ? state.sessionAgentSelections.get(sessionId) : undefined,
currentContextAgent: isInActiveTurn && sessionId ? state.currentAgentContext.get(sessionId) : undefined,
savedSessionAgentSelection: isInActiveTurn && sessionId ? state.sessionAgentSelections.get(sessionId) : undefined,
}))
);
@@ -224,8 +220,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return message.parts;
}
return normalizeUserDisplayParts(message.parts);
}, [isUser, message.parts]);
return normalizeUserDisplayParts(message.parts, { planModeEnabled });
}, [isUser, message.parts, planModeEnabled]);
const previousUserMetadata = React.useMemo(() => {
if (isUser || !previousMessage) {
@@ -265,6 +261,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
}, [isUser, previousMessage]);
const previousIsModeSwitchMessage = React.useMemo(() => {
if (!planModeEnabled) return false;
if (isUser || !previousMessage) return false;
const parts = Array.isArray(previousMessage.parts) ? previousMessage.parts : [];
for (let i = 0; i < parts.length; i++) {
@@ -277,7 +274,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
}
}
return false;
}, [isUser, previousMessage]);
}, [isUser, planModeEnabled, previousMessage]);
const agentName = React.useMemo(() => {
if (isUser) return undefined;
@@ -589,11 +586,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
if (isMessageCompleted) {
return 'completed';
}
if (lifecyclePhase) {
return lifecyclePhase;
if (isInActiveTurn) {
return activeStreamingPhase ?? 'streaming';
}
return isStreamingMessage ? 'streaming' : 'completed';
}, [isMessageCompleted, lifecyclePhase, isStreamingMessage]);
return 'completed';
}, [activeStreamingPhase, isInActiveTurn, isMessageCompleted]);
React.useEffect(() => {
if (!isUser || !animateUserOnMount) {
@@ -607,7 +604,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
}, [message.info.id]);
React.useEffect(() => {
const headerMessageId = turnGroupingContext?.headerMessageId;
const headerMessageId = assistantHeaderMessageId ?? turnGroupingContext?.headerMessageId;
if (isUser || !headerMessageId || headerMessageId !== message.info.id) {
return;
}
@@ -616,13 +613,13 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
if (isCurrentlyStreaming) {
setHasStartedStreamingHeader(true);
}
}, [isUser, message.info.id, streamPhase, turnGroupingContext?.headerMessageId]);
}, [assistantHeaderMessageId, isUser, message.info.id, streamPhase, turnGroupingContext?.headerMessageId]);
const shouldShowHeader = React.useMemo(() => {
if (isUser) return true;
// Use turn grouping context if available for more precise control
const headerMessageId = turnGroupingContext?.headerMessageId;
const headerMessageId = assistantHeaderMessageId ?? turnGroupingContext?.headerMessageId;
if (headerMessageId) {
// For turn grouping: only show header for the first assistant message in the turn
const isFirstAssistantInTurn = message.info.id === headerMessageId;
@@ -644,7 +641,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
// Ungrouped fallback path: always show assistant header.
return true;
}, [hasStartedStreamingHeader, isUser, turnGroupingContext, streamPhase, message.info.id]);
}, [assistantHeaderMessageId, hasStartedStreamingHeader, isUser, turnGroupingContext, streamPhase, message.info.id]);
const handleCopyCode = React.useCallback((code: string) => {
void copyTextToClipboard(code).then((result) => {
@@ -738,11 +735,14 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const handleCopyMessage = React.useCallback(async () => {
const result = await copyTextToClipboard(messageTextContent);
if (!result.ok) {
return;
return false;
}
setCopiedMessage(true);
setTimeout(() => setCopiedMessage(false), 2000);
}, [messageTextContent]);
if (isUser) {
setCopiedMessage(true);
setTimeout(() => setCopiedMessage(false), 2000);
}
return true;
}, [isUser, messageTextContent]);
const handleRevert = React.useCallback(() => {
if (!sessionId || !message.info.id) return;
@@ -971,7 +971,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const assistantTopPaddingClass = !isUser && shouldShowHeader
? (stickyUserHeader ? (isMobile ? 'pt-4' : 'pt-6') : 'pt-0')
: 'pt-0';
const userMessageRadius = 'var(--radius-lg)';
const userMessageRadius = 'var(--radius-xl)';
return (
<>
@@ -995,7 +995,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
respectReducedMotion
>
<div className={cn('relative flex justify-end', !isMobile ? 'group/user-shell' : undefined)}>
<div className="max-w-[85%]">
<div className={cn('max-w-[85%]', showStickyInlineHoverRow ? 'pb-5' : undefined)}>
<div
style={{
backgroundColor: 'var(--chat-user-message-bg)',
@@ -1068,8 +1068,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
/>
) : null}
</div>
{showStickyInlineHoverRow ? <div aria-hidden="true" className="pointer-events-none absolute left-0 right-0 top-full h-11" /> : null}
</div>
</div>
</FadeInOnReveal>
)
) : (
@@ -1086,6 +1085,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
)}
<MessageBody
sessionId={message.info.sessionID}
messageId={message.info.id}
parts={visibleParts}
isUser={isUser}
@@ -1131,4 +1131,28 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
);
};
export default React.memo(ChatMessage);
export default React.memo(ChatMessage, (prev, next) => {
return areRenderRelevantMessagesEqual(
{ info: prev.message.info, parts: prev.message.parts },
{ info: next.message.info, parts: next.message.parts }
)
&& areOptionalRenderRelevantMessagesEqual(
prev.previousMessage ? { info: prev.previousMessage.info, parts: prev.previousMessage.parts } : undefined,
next.previousMessage ? { info: next.previousMessage.info, parts: next.previousMessage.parts } : undefined
)
&& areOptionalRenderRelevantMessagesEqual(
prev.nextMessage ? { info: prev.nextMessage.info, parts: prev.nextMessage.parts } : undefined,
next.nextMessage ? { info: next.nextMessage.info, parts: next.nextMessage.parts } : undefined
)
&& prev.isInActiveTurn === next.isInActiveTurn
&& prev.activeStreamingPhase === next.activeStreamingPhase
&& prev.assistantHeaderMessageId === next.assistantHeaderMessageId
&& prev.animateUserOnMount === next.animateUserOnMount
&& prev.onUserAnimationConsumed === next.onUserAnimationConsumed
&& areRelevantTurnGroupingContextsEqual(
prev.turnGroupingContext,
next.turnGroupingContext,
prev.message.info.id,
deriveMessageRole(prev.message.info).isUser
);
});
@@ -1,18 +1,24 @@
import React from 'react';
import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsLine, RiTerminalBoxLine, RiArrowGoBackLine, RiArrowGoForwardLine, RiTimeLine } from '@remixicon/react';
import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsLine, RiTerminalBoxLine, RiArrowGoBackLine, RiArrowGoForwardLine, RiSearchEyeLine } from '@remixicon/react';
import { cn, fuzzyMatch } from '@/lib/utils';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionMessages } from '@/sync/sync-context';
import { useCommandsStore } from '@/stores/useCommandsStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useShallow } from 'zustand/react/shallow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useI18n } from '@/lib/i18n';
interface CommandInfo {
type CommandSource = 'openchamber' | 'opencode';
export interface CommandInfo {
id: string;
name: string;
source: CommandSource;
description?: string;
agent?: string;
model?: string;
isBuiltIn?: boolean;
isOpenChamber?: boolean;
isSkill?: boolean;
scope?: string;
}
@@ -42,22 +48,20 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
onTabSelect,
style,
}, ref) => {
const { hasMessagesInCurrentSession, currentSessionId } = useSessionStore(
useShallow((state) => {
const sessionId = state.currentSessionId;
const messageCount = sessionId ? (state.messages.get(sessionId)?.length ?? 0) : 0;
return {
hasMessagesInCurrentSession: messageCount > 0,
currentSessionId: sessionId,
};
})
);
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessionMessages = useSessionMessages(currentSessionId ?? '');
const hasMessagesInCurrentSession = sessionMessages.length > 0;
const hasSession = Boolean(currentSessionId);
const hasNewSessionDraft = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const canStartSessionCommand = hasSession || hasNewSessionDraft;
const [commands, setCommands] = React.useState<CommandInfo[]>([]);
const [loading, setLoading] = React.useState(false);
const { commands: commandsWithMetadata, loadCommands: refreshCommands } = useCommandsStore();
const { skills, loadSkills: refreshSkills } = useSkillsStore();
const commandsWithMetadata = useCommandsStore((s) => s.commands);
const refreshCommands = useCommandsStore((s) => s.loadCommands);
const skills = useSkillsStore((s) => s.skills);
const refreshSkills = useSkillsStore((s) => s.loadSkills);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const containerRef = React.useRef<HTMLDivElement | null>(null);
@@ -95,8 +99,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
setLoading(true);
try {
const skillNames = new Set(skills.map((skill) => skill.name));
const customCommands: CommandInfo[] = commandsWithMetadata.map(cmd => ({
const customCommands: CommandInfo[] = commandsWithMetadata.map((cmd, index) => ({
id: `opencode:${cmd.scope ?? 'global'}:${cmd.name}:${cmd.agent ?? ''}:${cmd.model ?? ''}:${index}`,
name: cmd.name,
source: 'opencode',
description: cmd.description,
agent: cmd.agent ?? undefined,
model: cmd.model ?? undefined,
@@ -107,27 +113,27 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
const builtInCommands: CommandInfo[] = [
...(hasSession && !hasMessagesInCurrentSession
? [{ name: 'init', description: 'Create/update AGENTS.md file', isBuiltIn: true }]
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.initDescription'), isBuiltIn: true }]
: []
),
...(hasSession // Show when session exists, not when hasMessages
? [
{ name: 'undo', description: 'Undo the last message', isBuiltIn: true },
{ name: 'redo', description: 'Redo previously undone messages', isBuiltIn: true },
{ name: 'timeline', description: 'Jump to a specific message', isBuiltIn: true },
{ id: 'openchamber:undo', name: 'undo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.undoDescription'), isBuiltIn: true },
{ id: 'openchamber:redo', name: 'redo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.redoDescription'), isBuiltIn: true },
]
: []
),
{ name: 'compact', description: 'Compress session history using AI to reduce context size', isBuiltIn: true },
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
...(hasSession
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
: []
),
...(canStartSessionCommand
? [{ id: 'openchamber:workspace-review', name: 'workspace-review', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.workspaceReviewDescription'), isOpenChamber: true }]
: []
),
];
const commandMap = new Map<string, CommandInfo>();
builtInCommands.forEach(cmd => commandMap.set(cmd.name, cmd));
customCommands.forEach(cmd => commandMap.set(cmd.name, cmd));
const allCommands = Array.from(commandMap.values());
const allCommands = [...builtInCommands, ...customCommands];
const allowInitCommand = !hasMessagesInCurrentSession;
const filtered = (searchQuery
@@ -151,18 +157,25 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
const allowInitCommand = !hasMessagesInCurrentSession;
const builtInCommands: CommandInfo[] = [
...(hasSession && !hasMessagesInCurrentSession
? [{ name: 'init', description: 'Create/update AGENTS.md file', isBuiltIn: true }]
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.initDescription'), isBuiltIn: true }]
: []
),
...(hasSession // Show when session exists, not when hasMessages
? [
{ name: 'undo', description: 'Undo the last message', isBuiltIn: true },
{ name: 'redo', description: 'Redo previously undone messages', isBuiltIn: true },
{ name: 'timeline', description: 'Jump to a specific message', isBuiltIn: true },
{ id: 'openchamber:undo', name: 'undo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.undoDescription'), isBuiltIn: true },
{ id: 'openchamber:redo', name: 'redo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.redoDescription'), isBuiltIn: true },
]
: []
),
{ name: 'compact', description: 'Compress session history using AI to reduce context size', isBuiltIn: true },
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
...(hasSession
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
: []
),
...(canStartSessionCommand
? [{ id: 'openchamber:workspace-review', name: 'workspace-review', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.workspaceReviewDescription'), isOpenChamber: true }]
: []
),
];
const filtered = (searchQuery
@@ -179,7 +192,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
};
loadCommands();
}, [searchQuery, hasMessagesInCurrentSession, hasSession, commandsWithMetadata, skills]);
}, [searchQuery, hasMessagesInCurrentSession, hasSession, canStartSessionCommand, commandsWithMetadata, skills, t]);
React.useEffect(() => {
setSelectedIndex(0);
@@ -233,10 +246,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
return <RiArrowGoBackLine className="h-3.5 w-3.5 text-orange-500" />;
case 'redo':
return <RiArrowGoForwardLine className="h-3.5 w-3.5 text-orange-500" />;
case 'timeline':
return <RiTimeLine className="h-3.5 w-3.5 text-blue-500" />;
case 'compact':
return <RiScissorsLine className="h-3.5 w-3.5 text-purple-500" />;
case 'review':
return <RiSearchEyeLine className="h-3.5 w-3.5 text-blue-500" />;
case 'test':
case 'build':
case 'run':
@@ -259,9 +272,9 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
<div className="px-2 pt-2 pb-1 border-b border-border/60">
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-elevated)] p-1">
{([
{ id: 'commands' as const, label: 'Commands' },
{ id: 'agents' as const, label: 'Agents' },
{ id: 'files' as const, label: 'Files' },
{ id: 'commands' as const, label: t('chat.autocomplete.tabs.commands') },
{ id: 'agents' as const, label: t('chat.autocomplete.tabs.agents') },
{ id: 'files' as const, label: t('chat.autocomplete.tabs.files') },
]).map((tab) => (
<button
key={tab.id}
@@ -304,11 +317,12 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
<div>
{commands.map((command, index) => {
const isSystem = command.isBuiltIn;
const isOpenChamberBadge = command.isOpenChamber;
const isProject = command.scope === 'project';
return (
<div
key={command.name}
key={command.id}
ref={(el) => { itemRefs.current[index] = el; }}
className={cn(
"flex items-start gap-2 px-3 py-2 cursor-pointer rounded-lg",
@@ -367,12 +381,23 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
<span className="typography-ui-label font-medium">/{command.name}</span>
{command.isSkill ? (
<span className="text-[10px] leading-none uppercase font-bold tracking-tight bg-[var(--status-info-background)] text-[var(--status-info)] border-[var(--status-info-border)] px-1.5 py-1 rounded border flex-shrink-0">
skill
{t('chat.commandAutocomplete.badge.skill')}
</span>
) : null}
{isSystem ? (
{isOpenChamberBadge ? (
<span
className="text-[10px] leading-none uppercase font-bold tracking-tight px-1.5 py-1 rounded border flex-shrink-0"
style={{
backgroundColor: 'color-mix(in srgb, var(--primary-base) 14%, transparent)',
color: 'var(--primary-base)',
borderColor: 'color-mix(in srgb, var(--primary-base) 28%, transparent)',
}}
>
OpenChamber
</span>
) : isSystem ? (
<span className="text-[10px] leading-none uppercase font-bold tracking-tight bg-[var(--status-warning-background)] text-[var(--status-warning)] border-[var(--status-warning-border)] px-1.5 py-1 rounded border flex-shrink-0">
system
{t('chat.commandAutocomplete.badge.system')}
</span>
) : command.scope ? (
<span className={cn(
@@ -401,19 +426,17 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
})}
{commands.length === 0 && (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
No commands found
{t('chat.commandAutocomplete.empty')}
</div>
)}
</div>
)}
</ScrollableOverlay>
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
navigate Enter select Esc close
{t('chat.autocomplete.keyboardHint')}
</div>
</div>
);
});
CommandAutocomplete.displayName = 'CommandAutocomplete';
export type { CommandInfo };
@@ -4,6 +4,23 @@ import { cn } from '@/lib/utils';
import { getLanguageFromExtension } from '@/lib/toolHelpers';
import { parseDiffToUnified } from './message/toolRenderers';
const DIFF_CUSTOM_STYLE: React.CSSProperties = {
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent',
backgroundColor: 'transparent',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'anywhere',
};
const DIFF_CODE_TAG_PROPS = {
style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } as React.CSSProperties,
};
interface DiffPreviewProps {
diff: string;
syntaxTheme: { [key: string]: React.CSSProperties };
@@ -46,21 +63,8 @@ export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, syntaxTheme, fil
PreTag="div"
wrapLines
wrapLongLines
customStyle={{
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent',
backgroundColor: 'transparent',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'anywhere',
}}
codeTagProps={{
style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' },
}}
customStyle={DIFF_CUSTOM_STYLE}
codeTagProps={DIFF_CODE_TAG_PROPS}
>
{line.content}
</SyntaxHighlighter>
@@ -104,21 +108,8 @@ export const WritePreview: React.FC<WritePreviewProps> = ({ content, syntaxTheme
PreTag="div"
wrapLines
wrapLongLines
customStyle={{
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent',
backgroundColor: 'transparent',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'anywhere',
}}
codeTagProps={{
style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' },
}}
customStyle={DIFF_CUSTOM_STYLE}
codeTagProps={DIFF_CODE_TAG_PROPS}
>
{line || ' '}
</SyntaxHighlighter>
@@ -1,6 +1,7 @@
import React, { useRef, memo } from 'react';
import { RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiGithubLine, RiGitPullRequestLine } from '@remixicon/react';
import { useSessionStore, type AttachedFile } from '@/stores/useSessionStore';
import { useInputStore } from '@/sync/input-store';
import type { AttachedFile } from '@/sync/session-ui-store';
import { useUIStore } from '@/stores/useUIStore';
import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
@@ -8,13 +9,15 @@ import { openExternalUrl } from '@/lib/url';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { useI18n } from '@/lib/i18n';
import type { ToolPopupContent } from './message/types';
export const FileAttachmentButton = memo(() => {
const { t } = useI18n();
const fileInputRef = useRef<HTMLInputElement>(null);
const { addAttachedFile } = useSessionStore();
const { isMobile } = useUIStore();
const addAttachedFile = useInputStore((state) => state.addAttachedFile);
const isMobile = useUIStore((state) => state.isMobile);
const isVSCodeRuntime = useIsVSCodeRuntime();
const buttonSizeClass = isMobile ? 'h-9 w-9' : 'h-7 w-7';
const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]';
@@ -26,7 +29,7 @@ export const FileAttachmentButton = memo(() => {
await addAttachedFile(file);
} catch (error) {
console.error('File attach failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
toast.error(error instanceof Error ? error.message : t('chat.fileAttachment.toast.attachFailed'));
}
}
};
@@ -49,8 +52,8 @@ export const FileAttachmentButton = memo(() => {
const skipped = Array.isArray(data?.skipped) ? data.skipped : [];
if (skipped.length > 0) {
const summary = skipped.map((s: { name?: string; reason?: string }) => `${s?.name || 'file'}: ${s?.reason || 'skipped'}`).join('\n');
toast.error(`Some files were skipped:\n${summary}`);
const summary = skipped.map((s: { name?: string; reason?: string }) => `${s?.name || t('chat.fileAttachment.fileFallback')}: ${s?.reason || t('chat.fileAttachment.skippedFallback')}`).join('\n');
toast.error(t('chat.fileAttachment.toast.someFilesSkipped', { summary }));
}
const asFiles = picked
@@ -66,7 +69,7 @@ export const FileAttachmentButton = memo(() => {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mime });
return new File([blob], file.name || 'file', { type: mime });
return new File([blob], file.name || t('chat.fileAttachment.fileFallback'), { type: mime });
} catch (err) {
console.error('Failed to decode VS Code picked file', err);
return null;
@@ -79,7 +82,7 @@ export const FileAttachmentButton = memo(() => {
}
} catch (error) {
console.error('VS Code file pick failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to pick files in VS Code');
toast.error(error instanceof Error ? error.message : t('chat.fileAttachment.toast.vscodePickFailed'));
}
};
@@ -102,13 +105,13 @@ export const FileAttachmentButton = memo(() => {
'hover:bg-muted text-muted-foreground',
buttonSizeClass
)}
aria-label="Attach files"
aria-label={t('chat.fileAttachment.actions.attachAria')}
>
<RiAttachment2 className={iconSizeClass} />
</button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Attach files</p>
<p>{t('chat.fileAttachment.actions.attach')}</p>
</TooltipContent>
</Tooltip>
</>
@@ -123,6 +126,7 @@ interface ImagePreviewProps {
}
const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
const { t } = useI18n();
const isLocalImagePreview =
file.source !== 'server' &&
file.mimeType.startsWith('image/') &&
@@ -162,7 +166,7 @@ const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
onRemove();
}}
className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
aria-label={`Remove ${displayName}`}
aria-label={t('chat.fileAttachment.actions.removeNamed', { name: displayName })}
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</span>
@@ -181,8 +185,8 @@ const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
<button
onClick={onRemove}
className="absolute top-0.5 right-0.5 h-4 w-4 rounded-full bg-background/80 text-foreground hover:text-destructive flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
title="Remove image"
aria-label={`Remove ${displayName}`}
title={t('chat.fileAttachment.actions.removeImage')}
aria-label={t('chat.fileAttachment.actions.removeNamed', { name: displayName })}
>
<RiCloseLine className="h-2.5 w-2.5" />
</button>
@@ -198,6 +202,7 @@ interface FileChipProps {
}
const FileChip = memo(({ file, onRemove }: FileChipProps) => {
const { t } = useI18n();
const getFileExtension = (filename: string): string => {
const parts = filename.split('.');
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
@@ -244,7 +249,7 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => {
onRemove();
}}
className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
aria-label={`Remove ${displayName}`}
aria-label={t('chat.fileAttachment.actions.removeNamed', { name: displayName })}
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</span>
@@ -255,7 +260,8 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => {
FileChip.displayName = 'FileChip';
export const AttachedFilesList = memo(() => {
const { attachedFiles, removeAttachedFile } = useSessionStore();
const attachedFiles = useInputStore((state) => state.attachedFiles);
const removeAttachedFile = useInputStore((state) => state.removeAttachedFile);
const localFiles = attachedFiles.filter((file) => file.source !== 'server');
@@ -516,7 +522,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
if (isImage && file.url) {
return (
<div
key={index}
key={file.url || `${fileName}-${index}`}
className="relative aspect-video rounded-lg border border-border/40 bg-muted/10 overflow-hidden group"
>
<img
@@ -536,7 +542,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
if (githubLinkKind && file.url) {
return (
<Tooltip key={index}>
<Tooltip key={file.url || `${fileName}-${index}`}>
<TooltipTrigger asChild>
<button
type="button"
@@ -569,7 +575,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
}
return (
<Tooltip key={index}>
<Tooltip key={file.url || `${fileName}-${index}`}>
<TooltipTrigger asChild>
<button
type="button"
@@ -640,7 +646,7 @@ export const ImageGallery = memo(({ urls, caption, onShowPopup }: ImageGalleryPr
<div className={cn("grid gap-2", getGridCols())}>
{urls.map((url, index) => (
<button
key={index}
key={url}
type="button"
onClick={() => onShowPopup?.({
open: true,
@@ -1,5 +1,5 @@
import React from 'react';
import { RiCodeLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiRefreshLine } from '@remixicon/react';
import { RiCodeLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiFolder3Fill, RiRefreshLine } from '@remixicon/react';
import { cn, truncatePathMiddle } from '@/lib/utils';
import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useConfigStore } from '@/stores/useConfigStore';
@@ -11,6 +11,7 @@ import type { ProjectFileSearchHit } from '@/lib/opencode/client';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { useI18n } from '@/lib/i18n';
type FileInfo = ProjectFileSearchHit;
type AgentInfo = {
@@ -18,6 +19,8 @@ type AgentInfo = {
description?: string;
mode?: string | null;
};
const EMPTY_FILES: FileInfo[] = [];
const EMPTY_AGENTS: AgentInfo[] = [];
export interface FileMentionHandle {
handleKeyDown: (key: string) => void;
@@ -46,6 +49,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
onTabSelect,
style,
}, ref) => {
const { t } = useI18n();
const currentDirectory = useChatSearchDirectory() ?? '';
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const activeProjectPath = useProjectsStore(
@@ -64,14 +68,16 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
[projectRoot],
),
);
const { getVisibleAgents } = useConfigStore();
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
const searchFiles = useFileSearchStore((state) => state.searchFiles);
const debouncedQuery = useDebouncedValue(searchQuery, 180);
const showHidden = useDirectoryShowHidden();
const showGitignored = useFilesViewShowGitignored();
const [files, setFiles] = React.useState<FileInfo[]>([]);
const [directories, setDirectories] = React.useState<FileInfo[]>([]);
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
const [loading, setLoading] = React.useState(false);
const pendingSearchRef = React.useRef(0);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const [marqueeWidth, setMarqueeWidth] = React.useState(360);
const [overflowMap, setOverflowMap] = React.useState<Record<number, boolean>>({});
@@ -82,8 +88,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
const containerRef = React.useRef<HTMLDivElement | null>(null);
const ignoreTabClickRef = React.useRef(false);
const normalizedSearchQuery = (searchQuery ?? '').trim();
const visibleAgents = normalizedSearchQuery.length > 0 ? agents : agents.slice(0, 2);
const scopeResultsToActiveTab = showTabs === true;
const recentFiles = React.useMemo(() => {
if (!projectRoot || !projectTabs) {
return [] as FileInfo[];
@@ -121,6 +126,15 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
return mapped;
}, [normalizedSearchQuery, projectRoot, projectTabs]);
const visibleAgents = React.useMemo(
() => !scopeResultsToActiveTab || activeTab === 'agents'
? (normalizedSearchQuery.length > 0 ? agents : agents.slice(0, 2))
: EMPTY_AGENTS,
[activeTab, agents, normalizedSearchQuery.length, scopeResultsToActiveTab],
);
const visibleDirectories = !scopeResultsToActiveTab || activeTab === 'files' ? directories : EMPTY_FILES;
const visibleRecentFiles = !scopeResultsToActiveTab || activeTab === 'files' ? recentFiles : EMPTY_FILES;
const visibleFiles = !scopeResultsToActiveTab || activeTab === 'files' ? files : EMPTY_FILES;
React.useEffect(() => {
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
@@ -143,7 +157,6 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
React.useEffect(() => {
if (!currentDirectory) {
setFiles([]);
setLoading(false);
return;
}
@@ -155,11 +168,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
if (!normalizedQueryLower) {
setFiles([]);
setLoading(false);
return;
}
let cancelled = false;
pendingSearchRef.current++;
setLoading(true);
searchFiles(currentDirectory, normalizedQueryLower, 80, {
@@ -182,15 +195,78 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
})
.finally(() => {
if (!cancelled) {
setLoading(false);
pendingSearchRef.current--;
if (pendingSearchRef.current <= 0) {
pendingSearchRef.current = 0;
setLoading(false);
}
}
});
return () => {
cancelled = true;
pendingSearchRef.current = Math.max(0, pendingSearchRef.current - 1);
if (pendingSearchRef.current <= 0) {
setLoading(false);
}
};
}, [currentDirectory, debouncedQuery, recentFiles, searchFiles, showHidden, showGitignored]);
React.useEffect(() => {
if (!currentDirectory) {
setDirectories([]);
return;
}
const normalizedQuery = (debouncedQuery ?? '').trim();
const normalizedQueryLower = normalizedQuery
.replace(/^\.\//, '')
.replace(/^\/+/, '')
.toLowerCase();
if (!normalizedQueryLower) {
setDirectories([]);
return;
}
let cancelled = false;
pendingSearchRef.current++;
setLoading(true);
searchFiles(currentDirectory, normalizedQueryLower, 20, {
includeHidden: showHidden,
respectGitignore: !showGitignored,
type: 'directory',
})
.then((hits) => {
if (!cancelled) {
setDirectories(hits.slice(0, 10));
}
})
.catch(() => {
if (!cancelled) {
setDirectories([]);
}
})
.finally(() => {
if (!cancelled) {
pendingSearchRef.current--;
if (pendingSearchRef.current <= 0) {
pendingSearchRef.current = 0;
setLoading(false);
}
}
});
return () => {
cancelled = true;
pendingSearchRef.current = Math.max(0, pendingSearchRef.current - 1);
if (pendingSearchRef.current <= 0) {
setLoading(false);
}
};
}, [currentDirectory, debouncedQuery, searchFiles, showHidden, showGitignored]);
React.useEffect(() => {
const visibleAgents = getVisibleAgents();
const normalizedQuery = (searchQuery ?? '').trim().toLowerCase();
@@ -214,7 +290,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
setSelectedIndex(0);
setOverflowMap({});
setMarqueeDurations({});
}, [files, recentFiles.length, visibleAgents.length]);
}, [visibleFiles, visibleDirectories, visibleRecentFiles.length, visibleAgents.length]);
React.useEffect(() => {
itemRefs.current[selectedIndex]?.scrollIntoView({
@@ -261,7 +337,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
}
window.removeEventListener('resize', updateOverflow);
};
}, [files]);
}, [visibleFiles, visibleDirectories]);
React.useEffect(() => {
const labelNode = labelRefs.current[selectedIndex];
@@ -305,7 +381,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
return;
}
const total = visibleAgents.length + recentFiles.length + files.length;
const total = visibleAgents.length + visibleDirectories.length + visibleRecentFiles.length + visibleFiles.length;
if (total === 0) {
return;
}
@@ -329,16 +405,24 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
}
return;
}
const fileIndex = safeIndex - visibleAgents.length;
const selectedFile = fileIndex < recentFiles.length
? recentFiles[fileIndex]
: files[fileIndex - recentFiles.length];
const dirIndex = safeIndex - visibleAgents.length;
if (dirIndex < visibleDirectories.length) {
const dir = visibleDirectories[dirIndex];
if (dir) {
handleFileSelect(dir);
}
return;
}
const fileIndex = dirIndex - visibleDirectories.length;
const selectedFile = fileIndex < visibleRecentFiles.length
? visibleRecentFiles[fileIndex]
: visibleFiles[fileIndex - visibleRecentFiles.length];
if (selectedFile) {
handleFileSelect(selectedFile);
}
}
}
}), [files, recentFiles, visibleAgents, selectedIndex, onClose, handleFileSelect, handleAgentPick]);
}), [visibleFiles, visibleDirectories, visibleRecentFiles, visibleAgents, selectedIndex, onClose, handleFileSelect, handleAgentPick]);
const getFileIcon = (file: FileInfo) => {
const ext = file.extension?.toLowerCase();
@@ -364,6 +448,12 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
}
};
const tabs = React.useMemo(() => ([
{ id: 'commands' as const, label: t('chat.autocomplete.tabs.commands') },
{ id: 'agents' as const, label: t('chat.autocomplete.tabs.agents') },
{ id: 'files' as const, label: t('chat.autocomplete.tabs.files') },
]), [t]);
return (
<div
ref={containerRef}
@@ -373,11 +463,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
{showTabs ? (
<div className="px-2 pt-2 pb-1 border-b border-border/60">
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-elevated)] p-1">
{([
{ id: 'commands' as const, label: 'Commands' },
{ id: 'agents' as const, label: 'Agents' },
{ id: 'files' as const, label: 'Files' },
]).map((tab) => (
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
@@ -411,7 +497,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
</div>
) : null}
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0">
{loading ? (
{(!scopeResultsToActiveTab || activeTab === 'files') && loading ? (
<div className="flex items-center justify-center py-4">
<RiRefreshLine className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
@@ -441,14 +527,41 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
})}
{visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
<div className="px-3 py-1 typography-meta text-muted-foreground">
Type to search more agents
{t('chat.fileMentionAutocomplete.searchMoreAgents')}
</div>
)}
{visibleAgents.length > 0 && (recentFiles.length > 0 || files.length > 0) && (
{visibleAgents.length > 0 && (visibleDirectories.length > 0 || visibleRecentFiles.length > 0 || visibleFiles.length > 0) && (
<div className="my-1 border-t border-border/60" />
)}
{recentFiles.map((file, index) => {
{visibleDirectories.map((dir, index) => {
const rowIndex = visibleAgents.length + index;
const relativePath = dir.relativePath || dir.name;
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
const isSelected = selectedIndex === rowIndex;
return (
<div
key={`dir-${dir.path}`}
ref={(el) => { itemRefs.current[rowIndex] = el; }}
className={cn(
"flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg",
isSelected && "bg-interactive-selection"
)}
onClick={() => handleFileSelect(dir)}
onMouseEnter={() => setSelectedIndex(rowIndex)}
>
<RiFolder3Fill className="h-3.5 w-3.5 text-primary/60" />
<span className="flex-1 min-w-0 truncate" aria-label={relativePath}>
{displayPath}
</span>
</div>
);
})}
{visibleDirectories.length > 0 && (visibleRecentFiles.length > 0 || visibleFiles.length > 0) && (
<div className="my-1 border-t border-border/60" />
)}
{visibleRecentFiles.map((file, index) => {
const rowIndex = visibleAgents.length + visibleDirectories.length + index;
const relativePath = file.relativePath || file.name;
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
const isSelected = selectedIndex === rowIndex;
@@ -496,11 +609,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
</div>
);
})}
{recentFiles.length > 0 && files.length > 0 && (
{visibleRecentFiles.length > 0 && visibleFiles.length > 0 && (
<div className="my-1 border-t border-border/60" />
)}
{files.map((file, index) => {
const rowIndex = visibleAgents.length + recentFiles.length + index;
{visibleFiles.map((file, index) => {
const rowIndex = visibleAgents.length + visibleDirectories.length + visibleRecentFiles.length + index;
const relativePath = file.relativePath || file.name;
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
const isSelected = selectedIndex === rowIndex;
@@ -553,16 +666,16 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
</React.Fragment>
);
})}
{files.length === 0 && recentFiles.length === 0 && visibleAgents.length === 0 && (
{visibleFiles.length === 0 && visibleDirectories.length === 0 && visibleRecentFiles.length === 0 && visibleAgents.length === 0 && (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
No matches found
{t('chat.fileMentionAutocomplete.empty')}
</div>
)}
</div>
)}
</ScrollableOverlay>
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
navigate Enter select Esc close
{t('chat.autocomplete.keyboardHint')}
</div>
</div>
);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,8 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import { getAgentDisplayName } from './mobileControlsUtils';
import { getAgentColor } from '@/lib/agentColors';
@@ -15,9 +16,10 @@ const LONG_PRESS_MS = 500;
// NOTE: Use pointer events instead of onClick to keep soft keyboard open on mobile
export const MobileAgentButton: React.FC<MobileAgentButtonProps> = ({ onCycleAgent, onOpenAgentPanel, className }) => {
const { currentAgentName, getVisibleAgents } = useConfigStore();
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const sessionAgentName = useSessionStore((state) =>
const currentAgentName = useConfigStore((state) => state.currentAgentName);
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessionAgentName = useSelectionStore((state) =>
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
);
@@ -9,7 +9,8 @@ interface MobileModelButtonProps {
}
export const MobileModelButton: React.FC<MobileModelButtonProps> = ({ onOpenModel, className }) => {
const { currentModelId, getCurrentProvider } = useConfigStore();
const currentModelId = useConfigStore((state) => state.currentModelId);
const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider);
const currentProvider = getCurrentProvider();
const modelLabel = getModelDisplayName(currentProvider, currentModelId);
@@ -1,5 +1,7 @@
import React from 'react';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import { useSessions, useAllSessionStatuses } from '@/sync/sync-context';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
@@ -36,8 +38,6 @@ import { CSS } from '@dnd-kit/utilities';
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { toast } from '@/components/ui';
import { isTauriShell, isDesktopLocalOriginActive, requestDirectoryAccess } from '@/lib/desktop';
import { sessionEvents } from '@/lib/sessionEvents';
import {
Dialog,
@@ -52,10 +52,11 @@ import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
import { useDrawerSwipe } from '@/hooks/useDrawerSwipe';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useNotificationStore } from '@/sync/notification-store';
import { useI18n } from '@/lib/i18n';
interface MobileSessionStatusBarProps {
onSessionSwitch?: (sessionId: string) => void;
cornerRadius?: number;
}
interface SessionWithStatus extends Session {
@@ -74,9 +75,10 @@ const normalize = (value: string): string => {
function useSessionGrouping(
sessions: Session[],
sessionStatus: Map<string, { type: string }> | undefined,
sessionAttentionStates: Map<string, { needsAttention: boolean }> | undefined
sessionStatus: Record<string, { type: string }> | undefined
) {
const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount);
const parentChildMap = React.useMemo(() => {
const map = new Map<string, Session[]>();
const allIds = new Set(sessions.map((s) => s.id));
@@ -91,7 +93,7 @@ function useSessionGrouping(
}, [sessions]);
const getStatusType = React.useCallback((sessionId: string): 'busy' | 'retry' | 'idle' => {
const status = sessionStatus?.get(sessionId);
const status = sessionStatus?.[sessionId];
if (status?.type === 'busy' || status?.type === 'retry') return status.type;
return 'idle';
}, [sessionStatus]);
@@ -115,9 +117,10 @@ function useSessionGrouping(
}, [parentChildMap, getStatusType]);
const processedSessions = React.useMemo(() => {
const sessionIds = new Set(sessions.map((s) => s.id));
const topLevel = sessions.filter((session) => {
const parentID = (session as { parentID?: string }).parentID;
return !parentID || !new Set(sessions.map((s) => s.id)).has(parentID);
return !parentID || !sessionIds.has(parentID);
});
const running: SessionWithStatus[] = [];
@@ -126,7 +129,7 @@ function useSessionGrouping(
topLevel.forEach((session) => {
const statusType = getStatusType(session.id);
const hasRunning = hasRunningChildren(session.id);
const attention = sessionAttentionStates?.get(session.id)?.needsAttention ?? false;
const attention = (unseenCounts[session.id] ?? 0) > 0;
const enriched: SessionWithStatus = {
...session,
@@ -155,28 +158,27 @@ function useSessionGrouping(
viewed.sort(sortByUpdated);
return [...running, ...viewed];
}, [sessions, getStatusType, hasRunningChildren, getRunningChildrenCount, getChildIndicators, sessionAttentionStates]);
}, [sessions, getStatusType, hasRunningChildren, getRunningChildrenCount, getChildIndicators, unseenCounts]);
const totalRunning = processedSessions.reduce((sum, s) => {
const selfRunning = s._statusType !== 'idle' ? 1 : 0;
return sum + selfRunning + (s._runningChildrenCount ?? 0);
}, 0);
const totalUnread = processedSessions.filter((s) => sessionAttentionStates?.get(s.id)?.needsAttention ?? false).length;
const totalUnread = processedSessions.filter((s) => (unseenCounts[s.id] ?? 0) > 0).length;
return { sessions: processedSessions, totalRunning, totalUnread, totalCount: processedSessions.length };
}
function useSessionHelpers(
agents: Array<{ name: string }>,
sessionStatus: Map<string, { type: string }> | undefined,
sessionAttentionStates: Map<string, { needsAttention: boolean }> | undefined
sessionStatus: Record<string, { type: string }> | undefined
) {
const getSessionAgentName = React.useCallback((session: Session): string => {
const agent = (session as { agent?: string }).agent;
if (agent) return agent;
const sessionAgentSelection = useSessionStore.getState().getSessionAgentSelection(session.id);
const sessionAgentSelection = useSelectionStore.getState().getSessionAgentSelection(session.id);
if (sessionAgentSelection) return sessionAgentSelection;
return agents[0]?.name ?? 'agent';
@@ -189,14 +191,14 @@ function useSessionHelpers(
}, []);
const isRunning = React.useCallback((sessionId: string): boolean => {
const status = sessionStatus?.get(sessionId);
const status = sessionStatus?.[sessionId];
return status?.type === 'busy' || status?.type === 'retry';
}, [sessionStatus]);
// Use server-authoritative attention state instead of local activity state
const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount);
const needsAttention = React.useCallback((sessionId: string): boolean => {
return sessionAttentionStates?.get(sessionId)?.needsAttention ?? false;
}, [sessionAttentionStates]);
return (unseenCounts[sessionId] ?? 0) > 0;
}, [unseenCounts]);
return { getSessionAgentName, getSessionTitle, isRunning, needsAttention };
}
@@ -204,17 +206,16 @@ function useSessionHelpers(
// Hook to calculate project status indicators
function useProjectStatus(
sessions: Session[],
sessionStatus: Map<string, { type: string }> | undefined,
sessionAttentionStates: Map<string, { needsAttention: boolean }> | undefined,
sessionStatus: Record<string, { type: string }> | undefined,
currentSessionId: string | null
) {
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory);
const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory);
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const getSessionsByDirectory = useSessionUIStore((state) => state.getSessionsByDirectory);
const notifUnseenCounts = useNotificationStore((s) => s.index.session.unseenCount);
const projectStatusMap = React.useCallback((projectPath: string): { hasRunning: boolean; hasUnread: boolean } => {
const getStatusType = (sessionId: string): 'busy' | 'retry' | 'idle' => {
const status = sessionStatus?.get(sessionId);
const status = sessionStatus?.[sessionId];
if (status?.type === 'busy' || status?.type === 'retry') return status.type;
return 'idle';
};
@@ -241,7 +242,7 @@ function useProjectStatus(
let hasUnread = false;
for (const dir of dirs) {
const list = sessionsByDirectory.get(dir) ?? getSessionsByDirectory(dir);
const list = getSessionsByDirectory(dir);
for (const session of list) {
if (!session?.id || seen.has(session.id)) {
continue;
@@ -253,7 +254,7 @@ function useProjectStatus(
hasRunning = true;
}
if (session.id !== currentSessionId && sessionAttentionStates?.get(session.id)?.needsAttention === true) {
if (session.id !== currentSessionId && (notifUnseenCounts[session.id] ?? 0) > 0) {
hasUnread = true;
}
@@ -267,7 +268,7 @@ function useProjectStatus(
}
return { hasRunning, hasUnread };
}, [sessionsByDirectory, getSessionsByDirectory, availableWorktreesByProject, sessionStatus, sessionAttentionStates, currentSessionId]);
}, [getSessionsByDirectory, availableWorktreesByProject, sessionStatus, notifUnseenCounts, currentSessionId]);
return projectStatusMap;
}
@@ -744,6 +745,7 @@ function ProjectEditPanel({
onDelete,
homeDirectory,
}: ProjectEditPanelProps) {
const { t } = useI18n();
const [localProjects, setLocalProjects] = React.useState(projects);
React.useEffect(() => {
@@ -796,10 +798,10 @@ function ProjectEditPanel({
<MobileOverlayPanel
open={isOpen}
onClose={onClose}
title="Edit Projects"
title={t('chat.mobileStatus.editProjects.title')}
footer={
<p className="text-xs text-[var(--surface-mutedForeground)] text-center">
Drag items to reorder, or use arrows to move. Tap edit to change details.
{t('chat.mobileStatus.editProjects.footer')}
</p>
}
>
@@ -831,7 +833,7 @@ function ProjectEditPanel({
{localProjects.length === 0 && (
<div className="text-center py-8 text-[var(--surface-mutedForeground)]">
No projects to edit
{t('chat.mobileStatus.editProjects.empty')}
</div>
)}
</div>
@@ -957,6 +959,7 @@ function ProjectBar({
onRemoveProject,
homeDirectory
}: ProjectBarProps) {
const { t } = useI18n();
const scrollRef = React.useRef<HTMLDivElement>(null);
const [editPanelOpen, setEditPanelOpen] = React.useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
@@ -1011,12 +1014,12 @@ function ProjectBar({
if (projects.length === 0) {
return (
<div className="flex items-center gap-2 px-2 py-1 border-b border-[var(--interactive-border)] bg-transparent">
<span className="text-[11px] text-[var(--surface-mutedForeground)]">No projects</span>
<span className="text-[11px] text-[var(--surface-mutedForeground)]">{t('chat.mobileStatus.projects.empty')}</span>
<button
type="button"
onClick={onAddProject}
className="flex items-center justify-center !py-1.5 px-2 rounded-md border border-[var(--primary-base)]/60 bg-[var(--primary-base)]/5 text-[var(--primary-base)]/80 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10 !min-h-0"
aria-label="Add project"
aria-label={t('chat.mobileStatus.projects.addAria')}
>
<RiAddLine className="h-3 w-3" />
</button>
@@ -1092,7 +1095,7 @@ function ProjectBar({
type="button"
onClick={onAddProject}
className="flex items-center justify-center !py-1.5 px-2 rounded-md border border-[var(--primary-base)]/60 bg-[var(--primary-base)]/5 text-[var(--primary-base)]/80 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10 shrink-0 !min-h-0"
aria-label="Add project"
aria-label={t('chat.mobileStatus.projects.addAria')}
>
<RiAddLine className="h-3.5 w-3.5" />
</button>
@@ -1101,17 +1104,17 @@ function ProjectBar({
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Remove Project</DialogTitle>
<DialogTitle>{t('chat.mobileStatus.projects.removeTitle')}</DialogTitle>
<DialogDescription>
Are you sure you want to remove <span className="font-medium text-foreground">{projectToDelete?.label || formatDirectoryName(projectToDelete?.path || '', homeDirectory)}</span>?
{t('chat.mobileStatus.projects.removeDescriptionPrefix')} <span className="font-medium text-foreground">{projectToDelete?.label || formatDirectoryName(projectToDelete?.path || '', homeDirectory)}</span>?
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
{t('chat.mobileStatus.projects.cancel')}
</Button>
<Button variant="destructive" onClick={handleConfirmDelete}>
Remove
{t('chat.mobileStatus.projects.remove')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1159,7 +1162,6 @@ function CollapsedView({
currentProjectColor,
onToggle,
onNewSession,
cornerRadius,
contextUsage,
childIndicators = [],
}: {
@@ -1173,18 +1175,18 @@ function CollapsedView({
currentProjectColor?: string | null;
onToggle: () => void;
onNewSession: () => void;
cornerRadius?: number;
contextUsage: SessionContextUsage | null;
childIndicators?: Array<{ session: Session; isRunning: boolean }>;
}) {
const { t } = useI18n();
const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe();
return (
<div
className="w-full flex items-center justify-between px-2 py-1 border-b border-[var(--interactive-border)] bg-[var(--surface-muted)] order-first text-left overflow-hidden"
style={{
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
borderTopLeftRadius: 'var(--radius-lg)',
borderTopRightRadius: 'var(--radius-lg)',
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
@@ -1220,7 +1222,7 @@ function CollapsedView({
}}
className="flex items-center gap-0.5 px-2 py-1 text-[12px] leading-tight !min-h-0 rounded border border-[var(--primary-base)]/60 bg-[var(--primary-base)]/5 text-[var(--primary-base)]/80 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10 self-center"
>
New
{t('chat.mobileStatus.new')}
</button>
</div>
</div>
@@ -1249,7 +1251,6 @@ function ExpandedView({
getSessionAgentName,
getSessionTitle,
needsAttention,
cornerRadius,
contextUsage,
projects,
activeProjectId,
@@ -1278,7 +1279,6 @@ function ExpandedView({
getSessionAgentName: (s: Session) => string;
getSessionTitle: (s: Session) => string;
needsAttention: (sessionId: string) => boolean;
cornerRadius?: number;
contextUsage: SessionContextUsage | null;
projects: ProjectEntry[];
activeProjectId: string | null;
@@ -1286,11 +1286,12 @@ function ExpandedView({
homeDirectory: string | null;
childIndicators?: Array<{ session: Session; isRunning: boolean }>;
}) {
const { t } = useI18n();
const containerRef = React.useRef<HTMLDivElement>(null);
const [collapsedHeight, setCollapsedHeight] = React.useState<number | null>(null);
const [hasMeasured, setHasMeasured] = React.useState(false);
const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe();
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
React.useEffect(() => {
if (containerRef.current && !hasMeasured && !isExpanded) {
@@ -1334,8 +1335,8 @@ function ExpandedView({
<div
className="w-full border-b border-[var(--interactive-border)] bg-[var(--surface-muted)] order-first overflow-hidden"
style={{
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
borderTopLeftRadius: 'var(--radius-lg)',
borderTopRightRadius: 'var(--radius-lg)',
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
@@ -1379,7 +1380,7 @@ function ExpandedView({
}}
className="flex items-center gap-0.5 px-2 py-1 text-[12px] leading-tight !min-h-0 rounded border border-[var(--primary-base)]/60 bg-[var(--primary-base)]/5 text-[var(--primary-base)]/80 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10 self-start"
>
New
{t('chat.mobileStatus.new')}
</button>
</div>
</div>
@@ -1403,7 +1404,7 @@ function ExpandedView({
>
{displaySessions.length === 0 ? (
<div className="flex items-center justify-center py-3 text-[11px] text-[var(--surface-mutedForeground)]">
<span>No sessions in this project</span>
<span>{t('chat.mobileStatus.noSessionsInProject')}</span>
</div>
) : (
displaySessions.map((session) => (
@@ -1426,40 +1427,41 @@ function ExpandedView({
export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
onSessionSwitch,
cornerRadius,
}) => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const sessions = useSessionStore((state) => state.sessions);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const sessionStatus = useSessionStore((state) => state.sessionStatus);
const sessionAttentionStates = useSessionStore((state) => state.sessionAttentionStates);
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
const getContextUsage = useSessionStore((state) => state.getContextUsage);
const sessions = useSessions();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessionStatus = useAllSessionStatuses();
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
const agents = useConfigStore((state) => state.agents);
const { getCurrentModel } = useConfigStore();
const { isMobile, showMobileSessionStatusBar, isMobileSessionStatusBarCollapsed, setIsMobileSessionStatusBarCollapsed } = useUIStore();
const getCurrentModel = useConfigStore((state) => state.getCurrentModel);
const isMobile = useUIStore((state) => state.isMobile);
const showMobileSessionStatusBar = useUIStore((state) => state.showMobileSessionStatusBar);
const isMobileSessionStatusBarCollapsed = useUIStore((state) => state.isMobileSessionStatusBarCollapsed);
const setIsMobileSessionStatusBarCollapsed = useUIStore((state) => state.setIsMobileSessionStatusBarCollapsed);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
// Project store
const projects = useProjectsStore((state) => state.projects);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const setActiveProject = useProjectsStore((state) => state.setActiveProject);
const addProject = useProjectsStore((state) => state.addProject);
const removeProject = useProjectsStore((state) => state.removeProject);
const getActiveProject = useProjectsStore((state) => state.getActiveProject);
// Directory store
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const { sessions: sortedSessions, totalRunning, totalUnread, totalCount } = useSessionGrouping(sessions, sessionStatus, sessionAttentionStates);
const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents, sessionStatus, sessionAttentionStates);
const getProjectStatus = useProjectStatus(sessions, sessionStatus, sessionAttentionStates, currentSessionId);
const { sessions: sortedSessions, totalRunning, totalUnread, totalCount } = useSessionGrouping(sessions, sessionStatus);
const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents, sessionStatus);
const getProjectStatus = useProjectStatus(sessions, sessionStatus, currentSessionId);
const currentSession = sessions.find((s) => s.id === currentSessionId);
const currentSessionTitle = currentSession
? getSessionTitle(currentSession)
: '← Swipe here to open sidebars →';
: t('chat.mobileStatus.swipeHint');
// Calculate current session's child indicators
const currentSessionWithStatus = sortedSessions.find((s) => s.id === currentSessionId);
@@ -1487,7 +1489,6 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
const contextUsage = getContextUsage(contextLimit, outputLimit);
const [isExpanded, setIsExpanded] = React.useState(false);
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
if (!isMobile || !showMobileSessionStatusBar || totalCount === 0) {
return null;
@@ -1515,29 +1516,7 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
};
const handleAddProject = () => {
if (!tauriIpcAvailable || !isDesktopLocalOriginActive()) {
sessionEvents.requestDirectoryDialog();
return;
}
requestDirectoryAccess('')
.then((result) => {
if (result.success && result.path) {
const added = addProject(result.path, { id: result.projectId });
if (!added) {
toast.error('Failed to add project', {
description: 'Please select a valid directory.',
});
}
} else if (result.error && result.error !== 'Directory selection cancelled') {
toast.error('Failed to select directory', {
description: result.error,
});
}
})
.catch((error) => {
console.error('Failed to select directory:', error);
toast.error('Failed to select directory');
});
sessionEvents.requestDirectoryDialog();
};
if (isMobileSessionStatusBarCollapsed) {
@@ -1553,7 +1532,6 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
currentProjectColor={currentProjectColor}
onToggle={() => setIsMobileSessionStatusBarCollapsed(false)}
onNewSession={handleCreateSession}
cornerRadius={cornerRadius}
contextUsage={contextUsage}
childIndicators={currentSessionChildIndicators}
/>
@@ -1586,7 +1564,6 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
getSessionAgentName={getSessionAgentName}
getSessionTitle={getSessionTitle}
needsAttention={needsAttention}
cornerRadius={cornerRadius}
contextUsage={contextUsage}
projects={projects}
activeProjectId={activeProjectId}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,150 @@
import React from 'react';
import { RiFileEditLine, RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useGitStore, useIsGitRepo } from '@/stores/useGitStore';
import { useUIStore } from '@/stores/useUIStore';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { sessionEvents } from '@/lib/sessionEvents';
import { normalizePath } from '@/components/session/sidebar/utils';
import {
type ChangedFileEntry,
type GitChangedFile,
extractGitChangedFiles,
isGitFile,
} from './changedFiles';
import { ChangedFilesList } from './ChangedFilesList';
import { changedFilesPopoverClassName, changedFilesPopoverStyle } from './changedFilesPopover';
import { useI18n } from '@/lib/i18n';
export const PendingChangesBar: React.FC = React.memo(() => {
const { t } = useI18n();
const [isExpanded, setIsExpanded] = React.useState(false);
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
const runtime = React.useContext(RuntimeAPIContext);
const isGitRepo = useIsGitRepo(currentDirectory);
const gitStatus = useGitStore((s) =>
currentDirectory ? s.directories.get(currentDirectory)?.status ?? null : null,
);
const ensureStatus = useGitStore((s) => s.ensureStatus);
const fetchStatus = useGitStore((s) => s.fetchStatus);
const popoverRef = React.useRef<HTMLDivElement>(null);
// Seed git store for currentDirectory so the bar can render independently of
// DiffView/GitView/right-sidebar mounting. ensureStatus has a 5s staleness
// gate and inFlightStatusFetchesByDirectory dedupes against concurrent callers.
React.useEffect(() => {
if (!currentDirectory || !runtime?.git) return;
void ensureStatus(currentDirectory, runtime.git);
}, [currentDirectory, runtime?.git, ensureStatus]);
// Mirror the onGitRefreshHint listener that lives in DiffView/GitView so the
// bar refreshes after mutating tools (edit/write/apply_patch/bash/...) even
// when neither of those views is open — e.g. VS Code runtime.
React.useEffect(() => {
if (!currentDirectory || !runtime?.git) return;
const git = runtime.git;
return sessionEvents.onGitRefreshHint((hint) => {
if (normalizePath(hint.directory) !== normalizePath(currentDirectory)) return;
void fetchStatus(currentDirectory, git);
});
}, [currentDirectory, runtime?.git, fetchStatus]);
const gitChangedFiles = React.useMemo<GitChangedFile[]>(() => {
if (isGitRepo !== true || !gitStatus || gitStatus.isClean) return [];
return extractGitChangedFiles(gitStatus.files, gitStatus.diffStats, currentDirectory);
}, [isGitRepo, gitStatus, currentDirectory]);
const { totalAdded, totalRemoved } = React.useMemo(() => {
let added = 0;
let removed = 0;
for (const file of gitChangedFiles) {
added += file.insertions;
removed += file.deletions;
}
return { totalAdded: added, totalRemoved: removed };
}, [gitChangedFiles]);
React.useEffect(() => {
if (!isExpanded) return;
const handleClickOutside = (event: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
setIsExpanded(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isExpanded]);
if (isGitRepo !== true) return null;
if (gitChangedFiles.length === 0) return null;
const handleOpenFile = (file: ChangedFileEntry) => {
if (!currentDirectory) return;
if (!isGitFile(file)) return;
const absolutePath = file.path.startsWith('/')
? file.path
: (currentDirectory.endsWith('/') ? currentDirectory : currentDirectory + '/') + file.path;
const editor = runtime?.editor;
if (editor) {
void editor.openFile(absolutePath);
return;
}
const store = useUIStore.getState();
if (!store.isMobile) {
store.openContextDiff(currentDirectory, file.relativePath);
return;
}
store.navigateToDiff(file.relativePath);
store.setRightSidebarOpen(false);
};
const fileCount = gitChangedFiles.length;
const labelHead = fileCount === 1
? t('chat.pendingChanges.fileCountSingle', { count: fileCount })
: t('chat.pendingChanges.fileCountPlural', { count: fileCount });
return (
<div className="relative flex min-w-0 items-center" ref={popoverRef}>
<button
type="button"
onClick={() => setIsExpanded((prev) => !prev)}
className="flex min-w-0 max-w-full items-center gap-1 text-left text-muted-foreground"
>
<RiFileEditLine className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]" />
<span className="min-w-0 typography-ui-label text-foreground flex-shrink-0">{labelHead}</span>
<span className="status-row__changed-label min-w-0 typography-ui-label text-foreground truncate">
{t('chat.pendingChanges.changedInWorkspace')}
</span>
<span className="text-[0.75rem] tabular-nums inline-flex items-baseline gap-1 flex-shrink-0">
{totalAdded > 0 ? <span style={{ color: 'var(--status-success)' }}>+{totalAdded}</span> : null}
{totalRemoved > 0 ? <span style={{ color: 'var(--status-error)' }}>-{totalRemoved}</span> : null}
</span>
{isExpanded ? (
<RiArrowUpSLine className="h-3.5 w-3.5 flex-shrink-0" />
) : (
<RiArrowDownSLine className="h-3.5 w-3.5 flex-shrink-0" />
)}
</button>
{isExpanded ? (
<div
style={changedFilesPopoverStyle}
className={`${changedFilesPopoverClassName} absolute z-50 left-0 bottom-full mb-1 slide-in-from-bottom-2`}
>
<ChangedFilesList
files={gitChangedFiles}
currentDirectory={currentDirectory}
onOpenFile={handleOpenFile}
/>
</div>
) : null}
</div>
);
});
PendingChangesBar.displayName = 'PendingChangesBar';
@@ -2,12 +2,45 @@ import React from 'react';
import { RiCheckLine, RiCloseLine, RiFileEditLine, RiGlobalLine, RiPencilAiLine, RiQuestionLine, RiTerminalBoxLine, RiTimeLine, RiToolsLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import type { PermissionRequest, PermissionResponse } from '@/types/permission';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions } from '@/sync/sync-context';
import * as sessionActions from '@/sync/session-actions';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { DiffPreview, WritePreview } from './DiffPreview';
import { useI18n } from '@/lib/i18n';
const PERMISSION_BASH_CUSTOM_STYLE: React.CSSProperties = {
margin: 0,
padding: '0.5rem',
fontSize: 'var(--text-meta)',
lineHeight: '1.25rem',
background: 'rgb(var(--muted) / 0.3)',
borderRadius: '0.25rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflowWrap: 'break-word',
overflow: 'visible',
};
const PERMISSION_BASH_CODE_TAG_PROPS = {
style: {
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflowWrap: 'break-word',
} as React.CSSProperties,
};
const PERMISSION_JSON_CUSTOM_STYLE: React.CSSProperties = {
margin: 0,
padding: '0.5rem',
fontSize: 'var(--text-meta)',
lineHeight: '1.25rem',
background: 'rgb(var(--muted) / 0.3)',
borderRadius: '0.25rem',
};
interface PermissionCardProps {
permission: PermissionRequest;
@@ -60,17 +93,17 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
permission,
onResponse
}) => {
const { t } = useI18n();
const [isResponding, setIsResponding] = React.useState(false);
const [hasResponded, setHasResponded] = React.useState(false);
const { respondToPermission } = useSessionStore();
const isFromSubagent = useSessionStore(
React.useCallback((state) => {
const currentSessionId = state.currentSessionId;
if (!currentSessionId || permission.sessionID === currentSessionId) return false;
const sourceSession = state.sessions.find((session) => session.id === permission.sessionID);
return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId);
}, [permission.sessionID])
);
const respondToPermission = sessionActions.respondToPermission;
const sessions = useSessions();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const isFromSubagent = React.useMemo(() => {
if (!currentSessionId || permission.sessionID === currentSessionId) return false;
const sourceSession = sessions.find((session) => session.id === permission.sessionID);
return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId);
}, [permission.sessionID, currentSessionId, sessions]);
const { currentTheme } = useThemeSystem();
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
@@ -81,7 +114,9 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
await respondToPermission(permission.sessionID, permission.id, response);
setHasResponded(true);
onResponse?.(response);
} catch { /* ignored */ } finally {
} catch (error) {
console.error('[PermissionCard] Failed to respond to permission:', error);
} finally {
setIsResponding(false);
}
};
@@ -122,12 +157,12 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
)}
{workingDir && (
<div className="typography-meta text-muted-foreground mb-2">
<span className="font-semibold">Working Directory:</span> <code className="px-1 py-0.5 bg-muted/30 rounded">{workingDir}</code>
<span className="font-semibold">{t('chat.permissionCard.workingDirectory')}</span> <code className="px-1 py-0.5 bg-muted/30 rounded">{workingDir}</code>
</div>
)}
{timeout && (
<div className="typography-meta text-muted-foreground mb-2">
<span className="font-semibold">Timeout:</span> {timeout}ms
<span className="font-semibold">{t('chat.permissionCard.timeout')}</span> {timeout}ms
</div>
)}
{}
@@ -137,25 +172,8 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
language="bash"
style={syntaxTheme}
PreTag="div"
customStyle={{
margin: 0,
padding: '0.5rem',
fontSize: 'var(--text-meta)',
lineHeight: '1.25rem',
background: 'rgb(var(--muted) / 0.3)',
borderRadius: '0.25rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflowWrap: 'break-word',
overflow: 'visible'
}}
codeTagProps={{
style: {
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflowWrap: 'break-word'
}
}}
customStyle={PERMISSION_BASH_CUSTOM_STYLE}
codeTagProps={PERMISSION_BASH_CODE_TAG_PROPS}
wrapLongLines={true}
>
{command}
@@ -214,7 +232,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
<>
{url && (
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">Request:</div>
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.request')}</div>
<div className="flex items-center gap-2">
<span className="typography-meta font-semibold px-1.5 py-0.5 bg-primary/20 text-primary rounded">
{method}
@@ -227,19 +245,12 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
)}
{headers && Object.keys(headers).length > 0 && (
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">Headers:</div>
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.headers')}</div>
<ScrollableOverlay outerClassName="max-h-24" className="p-0">
<SyntaxHighlighter
language="json"
style={syntaxTheme}
customStyle={{
margin: 0,
padding: '0.5rem',
fontSize: 'var(--text-meta)',
lineHeight: '1.25rem',
background: 'rgb(var(--muted) / 0.3)',
borderRadius: '0.25rem'
}}
customStyle={PERMISSION_JSON_CUSTOM_STYLE}
wrapLongLines={true}
>
{JSON.stringify(headers, null, 2)}
@@ -249,19 +260,12 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
)}
{body && (
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">Body:</div>
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.body')}</div>
<ScrollableOverlay outerClassName="max-h-32" className="p-0">
<SyntaxHighlighter
language={typeof body === 'object' ? 'json' : 'text'}
style={syntaxTheme}
customStyle={{
margin: 0,
padding: '0.5rem',
fontSize: 'var(--text-meta)',
lineHeight: '1.25rem',
background: 'rgb(var(--muted) / 0.3)',
borderRadius: '0.25rem'
}}
customStyle={PERMISSION_JSON_CUSTOM_STYLE}
wrapLongLines={true}
>
{typeof body === 'object' ? JSON.stringify(body, null, 2) : String(body)}
@@ -290,7 +294,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
)}
{genericContent && (
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">Action:</div>
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.action')}</div>
<ScrollableOverlay outerClassName="max-h-32" className="p-0">
<pre className="typography-meta font-mono px-2 py-1 bg-muted/30 rounded whitespace-pre-wrap break-all">
{String(genericContent)}
@@ -301,7 +305,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
{}
{Object.keys(permission.metadata).length > 0 && !genericContent && !description && (
<div>
<div className="typography-meta text-muted-foreground mb-1">Details:</div>
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.details')}</div>
<ScrollableOverlay outerClassName="max-h-32" className="p-0">
<pre className="typography-meta font-mono px-2 py-1 bg-muted/30 rounded whitespace-pre-wrap break-all">
{JSON.stringify(permission.metadata, null, 2)}
@@ -342,7 +346,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
<div className="px-2 py-2">
{permission.patterns.length > 0 && (
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">Patterns:</div>
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.patterns')}</div>
<code className="typography-meta px-2 py-1 bg-muted/30 rounded block break-all">
{permission.patterns.join(", ")}
</code>
@@ -2,7 +2,8 @@ import React from 'react';
import { RiCheckLine, RiCloseLine, RiTimeLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import type { PermissionRequest as PermissionRequestPayload, PermissionResponse } from '@/types/permission';
import { useSessionStore } from '@/stores/useSessionStore';
import * as sessionActions from '@/sync/session-actions';
import { useI18n } from '@/lib/i18n';
interface PermissionRequestProps {
permission: PermissionRequestPayload;
@@ -13,9 +14,10 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
permission,
onResponse
}) => {
const { t } = useI18n();
const [isResponding, setIsResponding] = React.useState(false);
const [hasResponded, setHasResponded] = React.useState(false);
const { respondToPermission } = useSessionStore();
const respondToPermission = sessionActions.respondToPermission;
const handleResponse = async (response: PermissionResponse) => {
setIsResponding(true);
@@ -24,7 +26,9 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
await respondToPermission(permission.sessionID, permission.id, response);
setHasResponded(true);
onResponse?.(response);
} catch { /* ignored */ } finally {
} catch (error) {
console.error('[PermissionRequest] Failed to respond to permission:', error);
} finally {
setIsResponding(false);
}
};
@@ -42,7 +46,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className="min-w-0">
<span className="typography-ui-label font-medium text-muted-foreground">
Permission required:
{t('chat.permissionRequest.required')}
</span>
<code className="ml-2 typography-meta bg-amber-100/50 dark:bg-amber-800/30 px-1.5 py-0.5 rounded font-mono text-amber-800 dark:text-amber-200">
{command}
@@ -70,7 +74,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
}}
>
<RiCheckLine className="h-3 w-3" />
Once
{t('chat.permissionRequest.actions.once')}
</button>
<button
@@ -92,7 +96,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
}}
>
<RiTimeLine className="h-3 w-3" />
Always
{t('chat.permissionRequest.actions.always')}
</button>
<button
@@ -114,7 +118,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
}}
>
<RiCloseLine className="h-3 w-3" />
Reject
{t('chat.permissionRequest.actions.reject')}
</button>
{isResponding && (
@@ -125,4 +129,4 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
</div>
</div>
);
};
};
@@ -1,5 +1,6 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
interface PermissionToastActionsProps {
sessionTitle: string;
@@ -27,10 +28,11 @@ export const PermissionToastActions: React.FC<PermissionToastActionsProps> = ({
onAlways,
onDeny,
}) => {
const { t } = useI18n();
const [isBusy, setIsBusy] = React.useState(false);
const actionContext = sessionTitle.trim().length > 0 ? ` for ${sessionTitle}` : '';
const sessionPreview = truncateToastText(sessionTitle, 64) || 'Session';
const permissionPreview = truncateToastText(permissionBody, 120) || 'Permission details unavailable';
const hasSessionTitle = sessionTitle.trim().length > 0;
const sessionPreview = truncateToastText(sessionTitle, 64) || t('chat.permissionToast.sessionFallback');
const permissionPreview = truncateToastText(permissionBody, 120) || t('chat.permissionToast.permissionFallback');
const handleAction = async (action: () => Promise<void> | void) => {
if (isBusy || disabled) return;
@@ -46,13 +48,13 @@ export const PermissionToastActions: React.FC<PermissionToastActionsProps> = ({
<div className="min-w-0">
<div className="mb-1.5 min-w-0 space-y-0.5">
<p className="typography-meta text-muted-foreground" title={sessionTitle}>
Session:{' '}
{t('chat.permissionToast.labels.session')}{' '}
<span className="inline-block max-w-[280px] align-bottom truncate text-foreground">
{sessionPreview}
</span>
</p>
<p className="typography-meta text-muted-foreground" title={permissionBody}>
Permission:{' '}
{t('chat.permissionToast.labels.permission')}{' '}
<span className="inline-block max-w-[280px] align-bottom truncate">
{permissionPreview}
</span>
@@ -63,7 +65,9 @@ export const PermissionToastActions: React.FC<PermissionToastActionsProps> = ({
<button
onClick={() => handleAction(onOnce)}
disabled={disabled || isBusy}
aria-label={`Approve once${actionContext}`}
aria-label={hasSessionTitle
? t('chat.permissionToast.actions.approveOnceAriaWithSession', { session: sessionTitle })
: t('chat.permissionToast.actions.approveOnceAria')}
className={cn(
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
"disabled:opacity-50 disabled:cursor-not-allowed"
@@ -79,13 +83,15 @@ export const PermissionToastActions: React.FC<PermissionToastActionsProps> = ({
e.currentTarget.style.backgroundColor = 'rgb(var(--status-success) / 0.1)';
}}
>
Once
{t('chat.permissionToast.actions.once')}
</button>
<button
onClick={() => handleAction(onAlways)}
disabled={disabled || isBusy}
aria-label={`Approve always${actionContext}`}
aria-label={hasSessionTitle
? t('chat.permissionToast.actions.approveAlwaysAriaWithSession', { session: sessionTitle })
: t('chat.permissionToast.actions.approveAlwaysAria')}
className={cn(
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
"disabled:opacity-50 disabled:cursor-not-allowed"
@@ -101,13 +107,15 @@ export const PermissionToastActions: React.FC<PermissionToastActionsProps> = ({
e.currentTarget.style.backgroundColor = 'rgb(var(--muted) / 0.5)';
}}
>
Always
{t('chat.permissionToast.actions.always')}
</button>
<button
onClick={() => handleAction(onDeny)}
disabled={disabled || isBusy}
aria-label={`Deny permission${actionContext}`}
aria-label={hasSessionTitle
? t('chat.permissionToast.actions.denyAriaWithSession', { session: sessionTitle })
: t('chat.permissionToast.actions.denyAria')}
className={cn(
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
"disabled:opacity-50 disabled:cursor-not-allowed"
@@ -123,7 +131,7 @@ export const PermissionToastActions: React.FC<PermissionToastActionsProps> = ({
e.currentTarget.style.backgroundColor = 'rgb(var(--status-error) / 0.1)';
}}
>
Deny
{t('chat.permissionToast.actions.deny')}
</button>
</div>
</div>
@@ -1,10 +1,14 @@
import React from 'react';
import { RiArrowRightSLine, RiCheckLine, RiCloseLine, RiEditLine, RiListCheck3, RiQuestionLine } from '@remixicon/react';
import { Checkbox } from '@/components/ui/checkbox';
import { Radio } from '@/components/ui/radio';
import { cn } from '@/lib/utils';
import type { QuestionRequest } from '@/types/question';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions } from '@/sync/sync-context';
import * as sessionActions from '@/sync/session-actions';
import { useI18n } from '@/lib/i18n';
interface QuestionCardProps {
question: QuestionRequest;
@@ -14,15 +18,16 @@ type TabKey = string;
const SUMMARY_TAB = 'summary';
export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
const { respondToQuestion, rejectQuestion } = useSessionStore();
const isFromSubagent = useSessionStore(
React.useCallback((state) => {
const currentSessionId = state.currentSessionId;
if (!currentSessionId || question.sessionID === currentSessionId) return false;
const sourceSession = state.sessions.find((session) => session.id === question.sessionID);
return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId);
}, [question.sessionID])
);
const { t } = useI18n();
const respondToQuestion = sessionActions.respondToQuestion;
const rejectQuestion = sessionActions.rejectQuestion;;
const sessions = useSessions();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const isFromSubagent = React.useMemo(() => {
if (!currentSessionId || question.sessionID === currentSessionId) return false;
const sourceSession = sessions.find((session) => session.id === question.sessionID);
return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId);
}, [question.sessionID, currentSessionId, sessions]);
const [activeTab, setActiveTab] = React.useState<TabKey>('0');
const [isResponding, setIsResponding] = React.useState(false);
const [hasResponded, setHasResponded] = React.useState(false);
@@ -56,21 +61,21 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
}));
// Add summary tab when multiple questions
if (questions.length > 1) {
questionTabs.push({ value: SUMMARY_TAB, label: 'Summary' });
questionTabs.push({ value: SUMMARY_TAB, label: t('chat.questionCard.summaryTab') });
}
return questionTabs;
}, [questions]);
}, [questions, t]);
// Helper to get answer display for a question index
const getAnswerDisplay = React.useCallback((index: number): string => {
const isCustom = Boolean(customMode[index]);
if (isCustom) {
const value = (customText[index] ?? '').trim();
return value || '(no answer)';
return value || t('chat.questionCard.noAnswer');
}
const answers = selectedOptions[index] ?? [];
return answers.length > 0 ? answers.join(', ') : '(no answer)';
}, [customMode, customText, selectedOptions]);
return answers.length > 0 ? answers.join(', ') : t('chat.questionCard.noAnswer');
}, [customMode, customText, selectedOptions, t]);
const isMultiple = Boolean(activeQuestion?.multiple);
const selectedForActive = selectedOptions[activeIndex] ?? [];
@@ -194,10 +199,10 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
<div className="px-2 py-1.5 border-b border-border/20">
<div className="flex items-center gap-2">
<RiQuestionLine className="h-3.5 w-3.5 text-primary" />
<span className="typography-meta font-medium text-muted-foreground">Input needed</span>
<span className="typography-meta font-medium text-muted-foreground">{t('chat.questionCard.inputNeeded')}</span>
{isFromSubagent ? (
<span className="typography-micro text-muted-foreground px-1.5 py-0.5 rounded bg-foreground/5">
From subagent
{t('chat.questionCard.fromSubagent')}
</span>
) : null}
{activeHeader ? (
@@ -246,7 +251,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
<div className="space-y-2">
{questions.map((q, index) => {
const answer = getAnswerDisplay(index);
const hasAnswer = answer !== '(no answer)';
const hasAnswer = answer !== t('chat.questionCard.noAnswer');
return (
<button
key={index}
@@ -254,7 +259,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
onClick={() => setActiveTab(String(index))}
className="w-full text-left rounded px-1.5 py-1 hover:bg-interactive-hover/20 transition-colors"
>
<div className="typography-micro text-muted-foreground">{q.header || `Question ${index + 1}`}</div>
<div className="typography-micro text-muted-foreground">{q.header || t('chat.questionCard.questionFallback', { index: index + 1 })}</div>
<div className={cn(
'typography-meta',
hasAnswer ? 'text-foreground' : 'text-muted-foreground/50 italic'
@@ -270,7 +275,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
<div className="typography-meta font-medium text-foreground mb-1.5">{activeQuestion.question}</div>
{isMultiple ? (
<div className="typography-micro text-muted-foreground mb-1.5">Select multiple</div>
<div className="typography-micro text-muted-foreground mb-1.5">{t('chat.questionCard.selectMultiple')}</div>
) : null}
<div className="space-y-0.5">
@@ -293,11 +298,19 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
>
<div className="flex items-start gap-2">
<div className="mt-0.5 shrink-0">
<Checkbox
checked={selected}
onChange={() => handleToggleOption(option.label)}
disabled={isResponding}
/>
{isMultiple ? (
<Checkbox
checked={selected}
onChange={() => handleToggleOption(option.label)}
disabled={isResponding}
/>
) : (
<Radio
checked={selected}
onChange={() => handleToggleOption(option.label)}
disabled={isResponding}
/>
)}
</div>
<div className="min-w-0 flex-1">
@@ -309,7 +322,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
{option.label}
</span>
{recommended ? (
<span className="typography-micro text-primary/80">recommended</span>
<span className="typography-micro text-primary/80">{t('chat.questionCard.recommended')}</span>
) : null}
</div>
{option.description ? (
@@ -342,7 +355,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
'typography-meta',
isCustomActive ? 'text-foreground font-medium' : 'text-muted-foreground'
)}>
Other
{t('chat.questionCard.other')}
</span>
</div>
</button>
@@ -369,7 +382,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
el.style.height = `${Math.min(Math.max(el.scrollHeight, minHeight), maxHeight)}px`;
setCustomText((prev) => ({ ...prev, [activeIndex]: el.value }));
}}
placeholder="Your answer"
placeholder={t('chat.questionCard.yourAnswer')}
disabled={isResponding}
rows={2}
className="w-full bg-transparent border border-border/30 focus:border-primary rounded px-2 py-1 outline-none typography-meta text-foreground placeholder:text-muted-foreground/50 transition-colors resize-none overflow-hidden"
@@ -395,7 +408,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
)}
>
{requiredSatisfied ? <RiCheckLine className="h-3 w-3" /> : <RiArrowRightSLine className="h-3 w-3" />}
{requiredSatisfied ? 'Submit' : 'Next'}
{requiredSatisfied ? t('chat.questionCard.submit') : t('chat.questionCard.next')}
</button>
<button
@@ -409,7 +422,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
)}
>
<RiCloseLine className="h-3 w-3" />
Dismiss
{t('chat.questionCard.dismiss')}
</button>
{isResponding ? (
@@ -1,8 +1,9 @@
import React, { memo } from 'react';
import { RiCloseLine, RiMessage2Line } from '@remixicon/react';
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useFileStore } from '@/stores/fileStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInputStore } from '@/sync/input-store';
import { useI18n } from '@/lib/i18n';
interface QueuedMessageChipProps {
message: QueuedMessage;
@@ -11,6 +12,7 @@ interface QueuedMessageChipProps {
}
const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChipProps) => {
const { t } = useI18n();
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
// Get first line of message, truncated
@@ -38,11 +40,11 @@ const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChi
<span className="text-muted-foreground flex-shrink-0">
Queued
{attachmentCount > 0 && (
<span className="ml-1">+{attachmentCount} file{attachmentCount > 1 ? 's' : ''}</span>
<span className="ml-1">{t('chat.queuedMessage.attachments', { count: attachmentCount })}</span>
)}
</span>
<span className="text-foreground truncate">
{firstLine || '(empty)'}
{firstLine || t('chat.queuedMessage.empty')}
</span>
<span
onClick={(e) => {
@@ -50,7 +52,7 @@ const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChi
removeFromQueue(sessionId, message.id);
}}
className="flex items-center justify-center h-6 w-6 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
aria-label="Remove from queue"
aria-label={t('chat.queuedMessage.removeAria')}
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</span>
@@ -67,7 +69,7 @@ interface QueuedMessageChipsProps {
const EMPTY_QUEUE: QueuedMessage[] = [];
export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsProps) => {
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const queuedMessages = useMessageQueueStore(
React.useCallback(
(state) => {
@@ -84,10 +86,9 @@ export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsPro
const popped = popToInput(currentSessionId, message.id);
if (popped) {
// Restore attachments to file store if any
if (popped.attachments && popped.attachments.length > 0) {
const currentAttachments = useFileStore.getState().attachedFiles;
useFileStore.setState({
const currentAttachments = useInputStore.getState().attachedFiles;
useInputStore.setState({
attachedFiles: [...currentAttachments, ...popped.attachments]
});
}
@@ -30,7 +30,8 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
const [selectedIndex, setSelectedIndex] = React.useState(0);
const [filteredSkills, setFilteredSkills] = React.useState<SkillInfo[]>([]);
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const { skills, loadSkills } = useSkillsStore();
const skills = useSkillsStore((s) => s.skills);
const loadSkills = useSkillsStore((s) => s.loadSkills);
React.useEffect(() => {
// Always trigger loadSkills when autocomplete opens to ensure project context is fresh
@@ -1,25 +1,29 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useContextStore } from '@/stores/contextStore';
import { formatEffortLabel, getAgentDisplayName, getModelDisplayName } from './mobileControlsUtils';
const STATUS_CHIP_STYLE = {
height: '28px',
maxHeight: '28px',
minHeight: '28px',
};
interface StatusChipProps {
onClick: () => void;
className?: string;
}
export const StatusChip: React.FC<StatusChipProps> = ({ onClick, className }) => {
const {
currentModelId,
currentVariant,
currentAgentName,
getCurrentProvider,
getCurrentModelVariants,
getVisibleAgents,
} = useConfigStore();
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const currentModelId = useConfigStore((state) => state.currentModelId);
const currentVariant = useConfigStore((state) => state.currentVariant);
const currentAgentName = useConfigStore((state) => state.currentAgentName);
const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider);
const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants);
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessionAgentName = useContextStore((state) =>
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
);
@@ -44,11 +48,7 @@ export const StatusChip: React.FC<StatusChipProps> = ({ onClick, className }) =>
'focus:outline-none hover:bg-[var(--interactive-hover)]',
className
)}
style={{
height: '28px',
maxHeight: '28px',
minHeight: '28px',
}}
style={STATUS_CHIP_STYLE}
title={fullLabel}
>
<span className="shrink-0">{agentLabel}</span>
@@ -1,4 +1,5 @@
import React from "react";
import { useSessionUIStore } from '@/sync/session-ui-store';
import {
RiArrowDownSLine,
RiArrowUpDoubleLine,
@@ -9,12 +10,21 @@ import {
RiTimeLine,
} from "@remixicon/react";
import { cn } from "@/lib/utils";
import { useTodoStore, type TodoItem, type TodoPriority, type TodoStatus } from "@/stores/useTodoStore";
import { useSessionStore } from "@/stores/useSessionStore";
import { useDirectorySync } from "@/sync/sync-context";
import type { Todo } from "@opencode-ai/sdk/v2/client";
// Compat aliases for old TodoItem shape
type TodoItem = Todo & { id?: string };
type TodoStatus = string;
type TodoPriority = string;
import { useUIStore } from "@/stores/useUIStore";
import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
import { isVSCodeRuntime } from "@/lib/desktop";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useI18n } from "@/lib/i18n";
const STATUS_ROW_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "status-row" };
const statusConfig: Record<TodoStatus, { textClassName: string }> = {
in_progress: {
@@ -43,17 +53,17 @@ const priorityIcon: Record<TodoPriority, React.ReactNode> = {
low: <RiArrowDownSLine className="h-3.5 w-3.5" aria-hidden="true" />,
};
const statusLabel: Record<TodoStatus, string> = {
in_progress: "In progress",
pending: "Pending",
completed: "Completed",
cancelled: "Cancelled",
const statusLabelKey: Record<TodoStatus, string> = {
in_progress: "chat.statusRow.todo.status.inProgress",
pending: "chat.statusRow.todo.status.pending",
completed: "chat.statusRow.todo.status.completed",
cancelled: "chat.statusRow.todo.status.cancelled",
};
const priorityLabel: Record<TodoPriority, string> = {
high: "High priority",
medium: "Medium priority",
low: "Low priority",
const priorityLabelKey: Record<TodoPriority, string> = {
high: "chat.statusRow.todo.priority.high",
medium: "chat.statusRow.todo.priority.medium",
low: "chat.statusRow.todo.priority.low",
};
interface TodoItemRowProps {
@@ -61,7 +71,10 @@ interface TodoItemRowProps {
}
const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
const { t } = useI18n();
const config = statusConfig[todo.status] || statusConfig.pending;
const statusKey = statusLabelKey[todo.status] ?? statusLabelKey.pending;
const priorityKey = priorityLabelKey[todo.priority] ?? priorityLabelKey.medium;
const statusIcon =
todo.status === "in_progress" ? (
@@ -79,7 +92,7 @@ const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
<span className="flex-shrink-0">{statusIcon}</span>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
{statusLabel[todo.status] ?? statusLabel.pending}
{t(statusKey as never)}
</TooltipContent>
</Tooltip>
<span
@@ -102,7 +115,7 @@ const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{priorityLabel[todo.priority] ?? priorityLabel.medium}
{t(priorityKey as never)}
</TooltipContent>
</Tooltip>
</div>
@@ -128,6 +141,7 @@ interface StatusRowProps {
showAssistantStatus?: boolean;
showTodos?: boolean;
agentName?: string;
leftAccessory?: React.ReactNode;
}
export const StatusRow: React.FC<StatusRowProps> = ({
@@ -144,23 +158,27 @@ export const StatusRow: React.FC<StatusRowProps> = ({
showAssistantStatus = true,
showTodos = true,
agentName,
leftAccessory,
}) => {
const { t } = useI18n();
const [isExpanded, setIsExpanded] = React.useState(false);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const todos = useTodoStore((state) =>
currentSessionId ? state.sessionTodos.get(currentSessionId) ?? EMPTY_TODOS : EMPTY_TODOS
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const todosRecord = useDirectorySync((state) => state.todo);
const persistedSessionTodos = useTodosPersistStore(
React.useCallback(
(state) => (currentSessionId ? state.sessions[currentSessionId]?.todos : undefined),
[currentSessionId],
),
);
const loadTodos = useTodoStore((state) => state.loadTodos);
const { isMobile } = useUIStore();
const todos: TodoItem[] = React.useMemo(() => {
if (!currentSessionId) return EMPTY_TODOS;
const live = todosRecord[currentSessionId];
if (live && live.length > 0) return live;
return persistedSessionTodos ?? EMPTY_TODOS;
}, [todosRecord, persistedSessionTodos, currentSessionId]);
const isMobile = useUIStore((state) => state.isMobile);
const isCompact = isMobile || isVSCodeRuntime();
// Load todos when session changes
React.useEffect(() => {
if (currentSessionId) {
void loadTodos(currentSessionId);
}
}, [currentSessionId, loadTodos]);
// Filter out cancelled todos for display and keep original order.
// This prevents items from jumping around when status changes.
const visibleTodos = React.useMemo(() => {
@@ -189,17 +207,17 @@ export const StatusRow: React.FC<StatusRowProps> = ({
return { active, left };
}, [visibleTodos]);
const hasActiveTodos = visibleTodos.some((t) => t.status === "in_progress" || t.status === "pending");
const hasTodoContent = showTodos && hasActiveTodos;
const hasTodoContent = showTodos && statusSummary.left > 0;
const hasAssistantContent = showAssistantStatus && (
isWorking ||
Boolean(wasAborted) ||
Boolean(showAbortStatus)
);
const hasLeftAccessory = Boolean(leftAccessory);
// Original logic from ChatInput
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
const hasContent = hasAssistantContent || hasTodoContent;
const hasContent = hasAssistantContent || hasTodoContent || hasLeftAccessory;
// Close popover when clicking outside
const popoverRef = React.useRef<HTMLDivElement>(null);
@@ -217,6 +235,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
}, [isExpanded]);
const toggleExpanded = () => setIsExpanded((prev) => !prev);
const todoSummaryLabel = t('chat.statusRow.summary.activeLeft', {
active: statusSummary.active,
left: statusSummary.left,
});
// Abort button for mobile/vscode
const abortButton = showAbort && onAbort ? (
@@ -224,7 +246,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
type="button"
onClick={onAbort}
className="flex items-center justify-center h-[1.2rem] w-[1.2rem] text-[var(--status-error)] transition-opacity hover:opacity-80 focus-visible:outline-none flex-shrink-0"
aria-label="Stop generating"
aria-label={t('chat.statusRow.actions.stopGeneratingAria')}
>
<RiCloseCircleLine size={18} aria-hidden="true" />
</button>
@@ -236,17 +258,27 @@ export const StatusRow: React.FC<StatusRowProps> = ({
type="button"
onClick={toggleExpanded}
className="flex items-center gap-1 flex-shrink-0 text-muted-foreground"
aria-label={todoSummaryLabel}
title={todoSummaryLabel}
>
{/* Desktop: show task text; Mobile/VSCode: just "Tasks" */}
{!isCompact && activeTodo ? (
<span className="typography-ui-label text-foreground truncate max-w-[200px]">
<span className="status-row__active-todo typography-ui-label text-foreground truncate max-w-[200px]">
{activeTodo.content}
</span>
) : (
<span className="typography-ui-label">Tasks</span>
<span className="typography-ui-label">{t('chat.statusRow.tasksTitle')}</span>
)}
<span className="typography-meta">
{statusSummary.active} active · {statusSummary.left} left
<span className="typography-meta flex items-center gap-1 tabular-nums" aria-hidden="true">
<span className="flex items-center gap-0.5">
<RiRecordCircleLine className="h-3.5 w-3.5 text-[var(--status-info)]" />
{statusSummary.active}
</span>
<span>·</span>
<span className="flex items-center gap-0.5">
<RiTimeLine className="h-3.5 w-3.5" />
{statusSummary.left}
</span>
</span>
{isExpanded ? (
<RiArrowUpSLine className="h-3.5 w-3.5" />
@@ -262,15 +294,15 @@ export const StatusRow: React.FC<StatusRowProps> = ({
}
return (
<div className="chat-column mb-1" style={{ containerType: "inline-size" }}>
<div className="flex items-center justify-between py-0.5 gap-2 h-[1.2rem]">
{/* Left: Abort status or Working placeholder */}
<div className="flex-1 flex items-center overflow-hidden min-w-0">
<div className={cn("mb-1", !hasLeftAccessory && "chat-column")} style={STATUS_ROW_CONTAINER_STYLE}>
<div className={cn("flex items-center justify-between py-0.5 gap-2 h-[1.2rem]", hasLeftAccessory && "px-0.5")}>
{/* Left: Abort status or Working placeholder or leftAccessory */}
<div className={cn("flex-1 flex items-center min-w-0", hasLeftAccessory ? "pl-1.5" : "overflow-hidden")}>
{showAssistantStatus && showAbortStatus ? (
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
<span className="flex items-center gap-1.5 typography-ui-label">
<RiCloseCircleLine size={16} aria-hidden="true" />
Aborted
{t('chat.statusRow.aborted')}
</span>
</div>
) : showAssistantStatus && shouldRenderPlaceholder ? (
@@ -283,38 +315,45 @@ export const StatusRow: React.FC<StatusRowProps> = ({
retryInfo={retryInfo}
agentName={agentName}
/>
) : leftAccessory ? (
leftAccessory
) : null}
</div>
{/* Right: Abort (mobile only) + Todo */}
<div className="relative -mr-3 flex items-center gap-2 flex-shrink-0" ref={popoverRef}>
<div className={cn("relative flex items-center gap-2 flex-shrink-0", hasLeftAccessory ? "pr-1.5" : "-mr-3")} ref={popoverRef}>
{abortButton}
{todoTrigger}
{/* Popover dropdown */}
{isExpanded && hasActiveTodos && (
{isExpanded && hasTodoContent && (
<div
style={{ maxWidth: "calc(100cqw - 4ch)" }}
style={{
maxWidth: "min(28rem, calc(100cqw - 4ch))",
backgroundColor: "var(--surface-elevated)",
color: "var(--surface-elevated-foreground)",
}}
className={cn(
"absolute right-0 bottom-full mb-1 z-50",
"w-max min-w-[200px]",
"rounded-xl border border-border bg-background shadow-none",
"w-max min-w-[200px] rounded-xl p-1",
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)]",
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]",
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
"duration-150"
)}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<span className="typography-ui-label text-muted-foreground">Tasks</span>
<span className="typography-meta text-muted-foreground">
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
<span>{t('chat.statusRow.tasksTitle')}</span>
<span className="typography-meta tabular-nums">
{progress.completed}/{progress.total}
</span>
</div>
{/* Todo list */}
<div className="px-3 py-2 max-h-[200px] overflow-y-auto divide-y divide-border">
{visibleTodos.map((todo) => (
<TodoItemRow key={todo.id} todo={todo} />
<div className="px-1 max-h-[200px] overflow-y-auto">
{visibleTodos.map((todo, index) => (
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
))}
</div>
</div>
@@ -0,0 +1,44 @@
import React from 'react';
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { StatusRow } from './StatusRow';
/**
* Status row wrapper.
* Uses the dedicated assistant status hook so the row keeps accurate live activity
* labels while still limiting subscriptions to the active assistant message.
*/
export const StatusRowContainer: React.FC = React.memo(() => {
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const abortRecord = useSessionUIStore(
React.useCallback((state) => {
if (!currentSessionId) {
return null;
}
return state.sessionAbortFlags?.get(currentSessionId) ?? null;
}, [currentSessionId]),
);
const { working } = useAssistantStatus();
const currentAgentName = useConfigStore((state) => state.currentAgentName);
const wasAborted = Boolean(abortRecord && !abortRecord.acknowledged);
return (
<StatusRow
isWorking={working.isWorking}
statusText={working.statusText}
isGenericStatus={working.isGenericStatus}
isWaitingForPermission={working.isWaitingForPermission}
wasAborted={wasAborted || working.wasAborted}
abortActive={wasAborted || working.abortActive}
retryInfo={working.retryInfo}
showAssistantStatus
showTodos={false}
agentName={currentAgentName}
/>
);
});
StatusRowContainer.displayName = 'StatusRowContainer';
@@ -7,11 +7,12 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { useSessionStore } from '@/stores/useSessionStore';
import { useMessageStore } from '@/stores/messageStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionMessageRecords } from '@/sync/sync-context';
import { RiLoader4Line, RiSearchLine, RiTimeLine, RiGitBranchLine, RiArrowGoBackLine } from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import type { Part } from '@opencode-ai/sdk/v2';
import { useI18n } from '@/lib/i18n';
interface TimelineDialogProps {
open: boolean;
@@ -21,22 +22,6 @@ interface TimelineDialogProps {
onResumeToLatest?: () => void;
}
// Helper: format relative time (e.g., "2 hours ago")
function formatRelativeTime(timestamp: number): string {
const now = Date.now();
const diffMs = now - timestamp;
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return new Date(timestamp).toLocaleDateString();
}
export const TimelineDialog: React.FC<TimelineDialogProps> = ({
open,
onOpenChange,
@@ -44,17 +29,30 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
onScrollByTurnOffset,
onResumeToLatest,
}) => {
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const messages = useMessageStore((state) =>
currentSessionId ? state.messages.get(currentSessionId) || [] : []
);
const revertToMessage = useSessionStore((state) => state.revertToMessage);
const forkFromMessage = useSessionStore((state) => state.forkFromMessage);
const loadSessions = useSessionStore((state) => state.loadSessions);
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const messages = useSessionMessageRecords(currentSessionId ?? '');
const revertToMessage = useSessionUIStore((state) => state.revertToMessage);
const forkFromMessage = useSessionUIStore((state) => state.forkFromMessage);
const [forkingMessageId, setForkingMessageId] = React.useState<string | null>(null);
const [searchQuery, setSearchQuery] = React.useState('');
const formatRelativeTime = React.useCallback((timestamp: number): string => {
const now = Date.now();
const diffMs = now - timestamp;
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return t('chat.timeline.relative.justNow');
if (diffMins < 60) return t('chat.timeline.relative.minutesAgo', { count: diffMins });
if (diffHours < 24) return t('chat.timeline.relative.hoursAgo', { count: diffHours });
if (diffDays < 7) return t('chat.timeline.relative.daysAgo', { count: diffDays });
return new Date(timestamp).toLocaleDateString();
}, [t]);
// Filter user messages (reversed for newest first)
const userMessages = React.useMemo(() => {
const filtered = messages.filter(m => m.info.role === 'user');
@@ -78,7 +76,6 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
setForkingMessageId(messageId);
try {
await forkFromMessage(currentSessionId, messageId);
await loadSessions();
onOpenChange(false);
} finally {
setForkingMessageId(null);
@@ -93,17 +90,17 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RiTimeLine className="h-5 w-5" />
Conversation Timeline
{t('chat.timeline.title')}
</DialogTitle>
<DialogDescription>
Navigate to any point in the conversation or fork a new session
{t('chat.timeline.description')}
</DialogDescription>
</DialogHeader>
<div className="relative mt-2">
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search messages..."
placeholder={t('chat.timeline.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 w-full"
@@ -113,7 +110,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
<div className="flex-1 overflow-y-auto">
{filteredMessages.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
{searchQuery ? 'No messages found' : 'No messages in this session yet'}
{searchQuery ? t('chat.timeline.empty.search') : t('chat.timeline.empty.session')}
</div>
) : (
filteredMessages.map((message) => {
@@ -138,7 +135,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
{messageNumber}.
</span>
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
{preview || '[No text content]'}
{preview || t('chat.timeline.noTextContent')}
{preview && preview.length >= 80 && '…'}
</p>
@@ -162,7 +159,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
<RiArrowGoBackLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Revert from here</TooltipContent>
<TooltipContent sideOffset={6}>{t('chat.timeline.actions.revertFromHere')}</TooltipContent>
</Tooltip>
<Tooltip delayDuration={1000}>
@@ -183,7 +180,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
)}
</button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Fork from here</TooltipContent>
<TooltipContent sideOffset={6}>{t('chat.timeline.actions.forkFromHere')}</TooltipContent>
</Tooltip>
</div>
</div>
@@ -194,7 +191,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
</div>
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
<p className="typography-meta text-muted-foreground font-medium mb-2">Actions</p>
<p className="typography-meta text-muted-foreground font-medium mb-2">{t('chat.timeline.actions.title')}</p>
<div className="mb-2 flex items-center gap-2">
<button
type="button"
@@ -204,7 +201,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
onOpenChange(false);
}}
>
Previous turn
{t('chat.timeline.actions.previousTurn')}
</button>
<span className="text-muted-foreground/50">/</span>
<button
@@ -215,20 +212,20 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
onOpenChange(false);
}}
>
Latest
{t('chat.timeline.actions.latest')}
</button>
</div>
<div className="flex flex-col gap-1.5 typography-meta text-muted-foreground">
<div className="flex items-center gap-2">
<span>Click on a message to scroll to it in the conversation</span>
<span>{t('chat.timeline.help.clickMessage')}</span>
</div>
<div className="flex items-center gap-2">
<RiArrowGoBackLine className="h-4 w-4 flex-shrink-0" />
<span>Undo to this point (message text will populate input)</span>
<span>{t('chat.timeline.help.undoToPoint')}</span>
</div>
<div className="flex items-center gap-2">
<RiGitBranchLine className="h-4 w-4 flex-shrink-0" />
<span>Create a new session starting from here</span>
<span>{t('chat.timeline.help.createSessionFromHere')}</span>
</div>
</div>
</div>
@@ -0,0 +1,130 @@
import React from 'react';
import { RiFileEditLine, RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react';
import type { ToolPart } from '@opencode-ai/sdk/v2';
import { Popover } from '@base-ui/react/popover';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useIsGitRepo } from '@/stores/useGitStore';
import { useUIStore } from '@/stores/useUIStore';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import {
type ChangedFile,
type ChangedFileEntry,
FILE_EDIT_TOOLS,
extractChangedFiles,
isGitFile,
toRelativePath,
} from './changedFiles';
import { ChangedFilesList } from './ChangedFilesList';
import { changedFilesPopoverClassName, changedFilesPopoverStyle } from './changedFilesPopover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import type { TurnActivityRecord } from './lib/turns/types';
interface TurnChangedFilesDropdownProps {
activityParts: TurnActivityRecord[] | undefined;
}
export const TurnChangedFilesDropdown: React.FC<TurnChangedFilesDropdownProps> = React.memo(({ activityParts }) => {
const [isExpanded, setIsExpanded] = React.useState(false);
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
const triggerButtonRef = React.useRef<HTMLButtonElement | null>(null);
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
const runtime = React.useContext(RuntimeAPIContext);
const isGitRepo = useIsGitRepo(currentDirectory);
const changedFiles = React.useMemo<ChangedFile[]>(() => {
// Skip work entirely in git repos — the global PendingChangesBar handles those.
if (isGitRepo !== false) return [];
if (!activityParts || activityParts.length === 0) return [];
const toolParts: ToolPart[] = [];
for (const activity of activityParts) {
const part = activity.part;
if (part.type !== 'tool') continue;
if (!FILE_EDIT_TOOLS.has(part.tool)) continue;
toolParts.push(part);
}
if (toolParts.length === 0) return [];
return extractChangedFiles(toolParts);
}, [activityParts, isGitRepo]);
if (changedFiles.length === 0) return null;
const syncPortalContainer = () => {
const container = triggerButtonRef.current?.closest('[data-slot="dialog-content"], [role="dialog"]') as HTMLElement | null;
setPortalContainer(container || null);
};
const handleOpenFile = (file: ChangedFileEntry) => {
if (!currentDirectory) return;
if (isGitFile(file)) return;
const absolutePath = file.path.startsWith('/')
? file.path
: (currentDirectory.endsWith('/') ? currentDirectory : currentDirectory + '/') + file.path;
const editor = runtime?.editor;
if (editor) {
void editor.openFile(absolutePath);
setIsExpanded(false);
return;
}
const store = useUIStore.getState();
if (!store.isMobile) {
store.openContextFile(currentDirectory, absolutePath);
setIsExpanded(false);
return;
}
store.navigateToDiff(toRelativePath(file.path, currentDirectory));
store.setRightSidebarOpen(false);
setIsExpanded(false);
};
const fileCount = changedFiles.length;
const label = `${fileCount} file${fileCount !== 1 ? 's' : ''}`;
return (
<Popover.Root open={isExpanded} onOpenChange={setIsExpanded}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Popover.Trigger
render={
<button
ref={triggerButtonRef}
type="button"
className="flex items-center gap-1 text-sm text-muted-foreground/60 hover:text-muted-foreground tabular-nums"
aria-label={`${label} changed in this turn`}
onPointerDownCapture={syncPortalContainer}
onFocusCapture={syncPortalContainer}
>
<RiFileEditLine className="h-3.5 w-3.5" />
<span className="message-footer__label">{label}</span>
{isExpanded ? (
<RiArrowUpSLine className="h-3.5 w-3.5" />
) : (
<RiArrowDownSLine className="h-3.5 w-3.5" />
)}
</button>
}
/>
</TooltipTrigger>
<TooltipContent>{label} changed in this turn</TooltipContent>
</Tooltip>
<Popover.Portal container={portalContainer || undefined}>
<Popover.Positioner side="top" align="start" sideOffset={4} collisionPadding={8}>
<Popover.Popup
style={changedFilesPopoverStyle}
className={changedFilesPopoverClassName}
>
<ChangedFilesList
files={changedFiles}
currentDirectory={currentDirectory}
onOpenFile={handleOpenFile}
/>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
);
});
TurnChangedFilesDropdown.displayName = 'TurnChangedFilesDropdown';
@@ -1,258 +0,0 @@
import React from 'react';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { cn } from '@/lib/utils';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useContextStore } from '@/stores/contextStore';
import { useUIStore } from '@/stores/useUIStore';
import { useModelLists } from '@/hooks/useModelLists';
import {
formatEffortLabel,
getQuickEffortOptions,
parseEffortVariant,
} from './mobileControlsUtils';
const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
});
const formatTokens = (value?: number | null) => {
if (typeof value !== 'number' || Number.isNaN(value)) {
return null;
}
if (value === 0) {
return '0';
}
const formatted = COMPACT_NUMBER_FORMATTER.format(value);
return formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted;
};
interface UnifiedControlsDrawerProps {
open: boolean;
onClose: () => void;
onOpenModel: () => void;
onOpenEffort: () => void;
}
export const UnifiedControlsDrawer: React.FC<UnifiedControlsDrawerProps> = ({
open,
onClose,
onOpenModel,
onOpenEffort,
}) => {
const {
providers,
currentProviderId,
currentModelId,
currentVariant,
setProvider,
setModel,
setCurrentVariant,
getCurrentModelVariants,
getModelMetadata,
} = useConfigStore();
const { addRecentModel, addRecentEffort, recentEfforts } = useUIStore();
const { recentModelsList } = useModelLists();
const {
currentSessionId,
saveAgentModelForSession,
saveAgentModelVariantForSession,
} = useSessionStore();
const sessionAgentName = useContextStore((state) =>
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
);
const uiAgentName = currentSessionId ? (sessionAgentName || null) : null;
const recentModelsBase = recentModelsList.slice(0, 4);
const hasCurrentInRecents = recentModelsBase.some(
(entry) => entry.providerID === currentProviderId && entry.modelID === currentModelId
);
// If current model not in recents, prepend it so it's always visible
const recentModels = React.useMemo(() => {
if (hasCurrentInRecents || !currentProviderId || !currentModelId) {
return recentModelsBase;
}
const currentProvider = providers.find((p) => p.id === currentProviderId);
const currentModel = currentProvider?.models?.find((m) => m.id === currentModelId);
if (!currentModel) {
return recentModelsBase;
}
return [
{ providerID: currentProviderId, modelID: currentModelId, provider: currentProvider, model: currentModel },
...recentModelsBase.slice(0, 3),
];
}, [recentModelsBase, hasCurrentInRecents, currentProviderId, currentModelId, providers]);
const variants = getCurrentModelVariants();
const hasEffort = variants.length > 0;
const effortKey = currentProviderId && currentModelId ? `${currentProviderId}/${currentModelId}` : null;
const recentEffortsForModel = effortKey ? (recentEfforts[effortKey] ?? []) : [];
const recentEffortOptions = recentEffortsForModel
.map((variant) => parseEffortVariant(variant))
.filter((variant) => !variant || variants.includes(variant));
const fallbackEfforts = getQuickEffortOptions(variants);
const baseEfforts = fallbackEfforts.length > 0 ? fallbackEfforts : recentEffortOptions;
const quickEfforts = React.useMemo(() => {
const base = baseEfforts.slice(0, 4);
const orderedRecents = recentEffortOptions.slice().reverse();
for (const recent of orderedRecents) {
if (base.some((entry) => entry === recent)) {
continue;
}
base.unshift(recent);
base.splice(4);
}
if (!base.some((entry) => entry === currentVariant)) {
if (base.length > 0) {
base[0] = currentVariant;
} else {
base.push(currentVariant);
}
}
if (!base.some((entry) => entry === undefined)) {
base.push(undefined);
base.splice(4);
}
return base;
}, [baseEfforts, currentVariant, recentEffortOptions]);
const effortHasMore = variants.length + 1 > quickEfforts.length;
const handleModelSelect = (providerId: string, modelId: string) => {
const provider = providers.find((entry) => entry.id === providerId);
if (!provider) {
return;
}
const providerModels = Array.isArray(provider.models) ? provider.models : [];
const modelExists = providerModels.some((model) => model.id === modelId);
if (!modelExists) {
return;
}
const isRecentAlready = recentModelsList.some(
(entry) => entry.providerID === providerId && entry.modelID === modelId
);
setProvider(providerId);
setModel(modelId);
if (!isRecentAlready) {
addRecentModel(providerId, modelId);
}
if (currentSessionId && uiAgentName) {
saveAgentModelForSession(currentSessionId, uiAgentName, providerId, modelId);
}
};
const handleEffortSelect = (variant: string | undefined) => {
setCurrentVariant(variant);
if (currentProviderId && currentModelId) {
addRecentEffort(currentProviderId, currentModelId, variant);
}
if (currentSessionId && uiAgentName && currentProviderId && currentModelId) {
saveAgentModelVariantForSession(currentSessionId, uiAgentName, currentProviderId, currentModelId, variant);
}
};
return (
<MobileOverlayPanel open={open} onClose={onClose} title="Controls">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<div className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground">
Model
</div>
<div className="rounded-xl border border-border/40 overflow-hidden">
{recentModels.length === 0 && !hasCurrentInRecents && (
<div className="px-3 py-2 typography-meta text-muted-foreground">
No recent models
</div>
)}
{recentModels.map(({ providerID, modelID, model }) => {
const isSelected = providerID === currentProviderId && modelID === currentModelId;
const modelName = typeof model?.name === 'string' && model.name.trim().length > 0
? model.name
: modelID;
const metadata = getModelMetadata(providerID, modelID);
const ctxTokens = formatTokens(metadata?.limit?.context);
const outTokens = formatTokens(metadata?.limit?.output);
return (
<button
key={`recent-${providerID}-${modelID}`}
type="button"
onClick={() => handleModelSelect(providerID, modelID)}
className={cn(
'flex min-h-[44px] w-full items-center gap-2 border-b border-border/30 px-3 py-2 text-left last:border-b-0',
isSelected ? 'bg-primary/10' : ''
)}
>
<ProviderLogo providerId={providerID} className="h-4 w-4 flex-shrink-0" />
<span className="typography-meta font-medium text-foreground truncate min-w-0 flex-1">
{modelName}
</span>
{(ctxTokens || outTokens) && (
<span className="typography-micro text-muted-foreground whitespace-nowrap flex-shrink-0">
{ctxTokens && `${ctxTokens} ctx`}
{ctxTokens && outTokens && ' • '}
{outTokens && `${outTokens} out`}
</span>
)}
</button>
);
})}
<button
type="button"
onClick={onOpenModel}
className="flex min-h-[44px] w-full items-center justify-center border-t border-border/30 px-3 py-2 typography-meta font-medium text-muted-foreground"
aria-label="More models"
>
...
</button>
</div>
</div>
{hasEffort && (
<div className="flex flex-col gap-2">
<div className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground">
Effort
</div>
<div className="flex flex-wrap gap-2">
{quickEfforts.map((variant) => {
const isSelected = variant === currentVariant || (!variant && !currentVariant);
return (
<button
key={variant ?? 'default'}
type="button"
onClick={() => handleEffortSelect(variant)}
className={cn(
'inline-flex items-center rounded-full border px-2.5 py-1 typography-meta font-medium',
isSelected
? 'border-primary/30 bg-primary/10 text-foreground'
: 'border-border/40 text-muted-foreground hover:bg-interactive-hover/50'
)}
aria-pressed={isSelected}
>
{formatEffortLabel(variant)}
</button>
);
})}
{effortHasMore && (
<button
type="button"
onClick={onOpenEffort}
className="inline-flex items-center rounded-full border border-border/40 px-2.5 py-1 typography-meta font-medium text-muted-foreground hover:bg-interactive-hover/50"
aria-label="More effort options"
>
...
</button>
)}
</div>
</div>
)}
</div>
</MobileOverlayPanel>
);
};
export default UnifiedControlsDrawer;
@@ -0,0 +1,195 @@
import type { ToolPart } from '@opencode-ai/sdk/v2';
export interface ChangedFile {
path: string;
tool: string;
partId: string;
messageID: string;
additions?: number;
deletions?: number;
patch?: string;
}
export interface GitChangedFile {
path: string;
relativePath: string;
insertions: number;
deletions: number;
status: string;
}
export type ChangedFileEntry = ChangedFile | GitChangedFile;
export const FILE_EDIT_TOOLS = new Set(['edit', 'multiedit', 'write', 'apply_patch', 'create', 'file_write']);
export const isGitFile = (file: ChangedFileEntry): file is GitChangedFile => 'insertions' in file;
const parseCount = (value: unknown): number | undefined => {
if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.trunc(value));
return undefined;
};
const parsePatchStats = (patch: string): { added: number; removed: number } => {
let added = 0;
let removed = 0;
for (const line of patch.split('\n')) {
if (line.startsWith('+') && !line.startsWith('+++')) added++;
if (line.startsWith('-') && !line.startsWith('---')) removed++;
}
return { added, removed };
};
export const extractChangedFiles = (parts: ToolPart[]): ChangedFile[] => {
const files: ChangedFile[] = [];
const seen = new Set<string>();
for (const part of parts) {
if (part.type !== 'tool') continue;
if (!FILE_EDIT_TOOLS.has(part.tool)) continue;
const state = part.state as { metadata?: Record<string, unknown>; input?: Record<string, unknown>; status?: string };
if (state.status && state.status !== 'completed') continue;
const sizeBeforeThisPart = files.length;
const metadata = state.metadata;
const metaFiles = Array.isArray(metadata?.files) ? metadata.files : [];
for (const file of metaFiles) {
if (!file || typeof file !== 'object') continue;
const record = file as { relativePath?: string; filePath?: string; additions?: unknown; deletions?: unknown; patch?: unknown };
const rawPath = record.relativePath || record.filePath || '';
if (!rawPath || seen.has(rawPath)) continue;
seen.add(rawPath);
files.push({
path: rawPath,
tool: part.tool,
partId: part.id,
messageID: part.messageID,
additions: parseCount(record.additions) ?? undefined,
deletions: parseCount(record.deletions) ?? undefined,
patch: typeof record.patch === 'string' ? record.patch : undefined,
});
}
if (metaFiles.length === 0 && metadata?.filediff && typeof metadata.filediff === 'object') {
const fd = metadata.filediff as { file?: string; additions?: unknown; deletions?: unknown; patch?: unknown };
const rawPath = typeof fd.file === 'string' ? fd.file : '';
if (rawPath && !seen.has(rawPath)) {
seen.add(rawPath);
files.push({
path: rawPath,
tool: part.tool,
partId: part.id,
messageID: part.messageID,
additions: parseCount(fd.additions) ?? undefined,
deletions: parseCount(fd.deletions) ?? undefined,
patch: typeof fd.patch === 'string' ? fd.patch : undefined,
});
}
}
if (metaFiles.length === 0 && Array.isArray(metadata?.results)) {
for (const result of metadata.results) {
if (!result || typeof result !== 'object') continue;
const fd = (result as { filediff?: { file?: string; additions?: unknown; deletions?: unknown; patch?: unknown } }).filediff;
if (!fd || typeof fd !== 'object') continue;
const rawPath = typeof fd.file === 'string' ? fd.file : '';
if (!rawPath || seen.has(rawPath)) continue;
seen.add(rawPath);
files.push({
path: rawPath,
tool: part.tool,
partId: part.id,
messageID: part.messageID,
additions: parseCount(fd.additions) ?? undefined,
deletions: parseCount(fd.deletions) ?? undefined,
patch: typeof fd.patch === 'string' ? fd.patch : undefined,
});
}
}
if (files.length === sizeBeforeThisPart) {
const input = state.input;
const filePath = typeof input?.filePath === 'string' ? input.filePath
: typeof input?.file_path === 'string' ? input.file_path
: typeof input?.path === 'string' ? input.path
: undefined;
if (filePath && !seen.has(filePath)) {
seen.add(filePath);
files.push({
path: filePath,
tool: part.tool,
partId: part.id,
messageID: part.messageID,
});
}
}
if (files.length === sizeBeforeThisPart) {
const patchText = typeof metadata?.patch === 'string' ? metadata.patch.trim()
: typeof metadata?.diff === 'string' ? metadata.diff.trim() : '';
if (patchText && !seen.has('Diff')) {
seen.add('Diff');
const parsed = parsePatchStats(patchText);
files.push({
path: 'Diff',
tool: part.tool,
partId: part.id,
messageID: part.messageID,
additions: parsed.added,
deletions: parsed.removed,
});
}
}
}
return files;
};
export const extractGitChangedFiles = (
files: Array<{ path: string; index: string; working_dir: string }>,
diffStats: Record<string, { insertions: number; deletions: number }> | undefined,
directory: string,
): GitChangedFile[] => {
const result: GitChangedFile[] = [];
for (const file of files) {
const code = file.working_dir !== ' ' ? file.working_dir : file.index;
if (code === '!' || code === ' ') continue;
const stats = diffStats?.[file.path];
result.push({
path: file.path.startsWith('/') ? file.path : (directory.endsWith('/') ? directory : directory + '/') + file.path,
relativePath: file.path,
insertions: stats?.insertions ?? 0,
deletions: stats?.deletions ?? 0,
status: code,
});
}
return result;
};
export const toRelativePath = (absolutePath: string, baseDirectory: string): string => {
const norm = (p: string) => p.split('\\').join('/').replace(/\/+$/, '');
const base = norm(baseDirectory);
const absPath = norm(absolutePath);
if (absPath.startsWith(base + '/')) {
return absPath.slice(base.length + 1);
}
if (absPath.startsWith(base)) {
return absPath.slice(base.length) || absPath;
}
return absPath;
};
export const getDisplayPath = (file: ChangedFileEntry, currentDirectory: string): { fileName: string; dirPart: string } => {
const relativePath = isGitFile(file) && file.relativePath
? file.relativePath
: toRelativePath(file.path, currentDirectory);
const fileName = relativePath.split('/').pop() ?? relativePath;
const dirPart = relativePath.includes('/') ? relativePath.slice(0, relativePath.lastIndexOf('/')) : '';
return { fileName, dirPart };
};
export const getFileStats = (file: ChangedFileEntry): { additions: number; deletions: number } => {
if (isGitFile(file)) return { additions: file.insertions, deletions: file.deletions };
return { additions: file.additions ?? 0, deletions: file.deletions ?? 0 };
};
@@ -0,0 +1,10 @@
import type { CSSProperties } from 'react';
export const changedFilesPopoverClassName =
"w-max min-w-[280px] max-w-full rounded-xl p-1 shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)] dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)] animate-in fade-in-0 zoom-in-95 duration-150";
export const changedFilesPopoverStyle: CSSProperties = {
maxWidth: 'calc(100cqw - 4ch)',
backgroundColor: 'var(--surface-elevated)',
color: 'var(--surface-elevated-foreground)',
};
@@ -3,6 +3,7 @@ import { RiArrowDownLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
interface ScrollToBottomButtonProps {
visible: boolean;
@@ -10,6 +11,7 @@ interface ScrollToBottomButtonProps {
}
const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, onClick }) => {
const { t } = useI18n();
return (
<div
className={cn(
@@ -21,8 +23,8 @@ const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, on
variant="outline"
size="sm"
onClick={onClick}
className="rounded-full h-8 w-8 p-0 shadow-none bg-background/95 hover:bg-interactive-hover"
aria-label="Scroll to bottom"
className="size-8 rounded-full [corner-shape:round] p-0 shadow-none bg-background/95 hover:bg-interactive-hover"
aria-label={t('chat.scrollToBottom.aria')}
>
<RiArrowDownLine className="h-4 w-4" />
</Button>
@@ -28,6 +28,7 @@ interface TurnActivityProps {
animateRows?: boolean;
animatedToolIds?: Set<string>;
diffStats?: DiffStats;
renderJustificationActions?: (activity: TurnActivityRecord) => React.ReactNode;
}
const TurnActivity: React.FC<TurnActivityProps> = (props) => {
@@ -10,7 +10,18 @@ interface TurnListProps<TEntry extends TurnListEntry> {
}
const TurnList = <TEntry extends TurnListEntry>({ entries, renderEntry }: TurnListProps<TEntry>): React.ReactElement => {
return <>{entries.map((entry) => renderEntry(entry))}</>;
return (
<>
{entries.map((entry) => (
<div
key={entry.key}
data-turn-entry={entry.key}
>
{renderEntry(entry)}
</div>
))}
</>
);
};
export default React.memo(TurnList) as typeof TurnList;
@@ -7,25 +7,24 @@ import {
buildTurnWindowModel,
clampTurnStart,
getInitialTurnStart,
updateTurnWindowModelIncremental,
windowMessagesByTurn,
type TurnWindowModel,
} from '../lib/turns/windowTurns';
import type { TurnHistorySignals } from '../lib/turns/historySignals';
import { getMemoryLimits, type SessionHistoryMeta } from '@/stores/types/sessionTypes';
const waitForFrames = async (count = 1): Promise<void> => {
if (typeof window === 'undefined') {
return;
}
for (let index = 0; index < count; index += 1) {
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => resolve());
});
}
};
type ViewportAnchor = { messageId: string; offsetTop: number };
type PendingScrollRequest = {
sessionId: string;
kind: 'turn' | 'message';
id: string;
behavior: ScrollBehavior;
turnId: string | null;
resolve: (value: boolean) => void;
};
interface UseChatTimelineControllerOptions {
sessionId: string | null;
messages: ChatMessageEntry[];
@@ -33,7 +32,8 @@ interface UseChatTimelineControllerOptions {
scrollRef: React.RefObject<HTMLDivElement | null>;
messageListRef: React.RefObject<MessageListHandle | null>;
loadMoreMessages: (sessionId: string, direction: 'up' | 'down') => Promise<void>;
scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void;
prepareForBottomResume: (options?: { instant?: boolean; force?: boolean }) => void;
scrollToBottom: (options?: { instant?: boolean; force?: boolean; followBottom?: boolean }) => void;
isPinned: boolean;
isOverflowing: boolean;
}
@@ -66,11 +66,24 @@ export const useChatTimelineController = ({
scrollRef,
messageListRef,
loadMoreMessages,
prepareForBottomResume,
scrollToBottom,
isPinned,
isOverflowing,
}: UseChatTimelineControllerOptions): UseChatTimelineControllerResult => {
const turnWindowModel = React.useMemo(() => buildTurnWindowModel(messages), [messages]);
const previousTurnWindowModelRef = React.useRef<TurnWindowModel | null>(null);
const previousMessagesRef = React.useRef<ChatMessageEntry[] | null>(null);
const turnWindowModel = React.useMemo(() => {
const incrementalModel = updateTurnWindowModelIncremental(
previousTurnWindowModelRef.current,
previousMessagesRef.current,
messages,
);
const nextModel = incrementalModel ?? buildTurnWindowModel(messages);
previousTurnWindowModelRef.current = nextModel;
previousMessagesRef.current = messages;
return nextModel;
}, [messages]);
const [turnStart, setTurnStart] = React.useState(() => getInitialTurnStart(turnWindowModel.turnCount));
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
@@ -87,6 +100,8 @@ export const useChatTimelineController = ({
const historyMetaRef = React.useRef<SessionHistoryMeta | null>(historyMeta);
const previousTurnCountRef = React.useRef(turnWindowModel.turnCount);
const initializedSessionRef = React.useRef<string | null>(null);
const pendingRenderResolversRef = React.useRef<Array<() => void>>([]);
const pendingScrollRequestRef = React.useRef<PendingScrollRequest | null>(null);
const historySignals = React.useMemo(() => {
const defaultLimit = getMemoryLimits().HISTORICAL_MESSAGES;
@@ -105,43 +120,17 @@ export const useChatTimelineController = ({
const historySignalsRef = React.useRef(historySignals);
React.useEffect(() => {
turnModelRef.current = turnWindowModel;
}, [turnWindowModel]);
turnModelRef.current = turnWindowModel;
turnStartRef.current = turnStart;
isPinnedRef.current = isPinned;
isLoadingOlderRef.current = isLoadingOlder;
pendingRevealWorkRef.current = pendingRevealWork;
historySignalsRef.current = historySignals;
sessionIdRef.current = sessionId;
messagesRef.current = messages;
historyMetaRef.current = historyMeta;
React.useEffect(() => {
turnStartRef.current = turnStart;
}, [turnStart]);
React.useEffect(() => {
isPinnedRef.current = isPinned;
}, [isPinned]);
React.useEffect(() => {
isLoadingOlderRef.current = isLoadingOlder;
}, [isLoadingOlder]);
React.useEffect(() => {
pendingRevealWorkRef.current = pendingRevealWork;
}, [pendingRevealWork]);
React.useEffect(() => {
historySignalsRef.current = historySignals;
}, [historySignals]);
React.useEffect(() => {
sessionIdRef.current = sessionId;
}, [sessionId]);
React.useEffect(() => {
messagesRef.current = messages;
}, [messages]);
React.useEffect(() => {
historyMetaRef.current = historyMeta;
}, [historyMeta]);
React.useEffect(() => {
React.useLayoutEffect(() => {
if (initializedSessionRef.current === sessionId) {
return;
}
@@ -153,11 +142,11 @@ export const useChatTimelineController = ({
previousTurnCountRef.current = turnWindowModel.turnCount;
}, [sessionId, turnWindowModel.turnCount]);
React.useEffect(() => {
React.useLayoutEffect(() => {
setTurnStart((current) => clampTurnStart(current, turnWindowModel.turnCount));
}, [turnWindowModel.turnCount]);
React.useEffect(() => {
React.useLayoutEffect(() => {
const previousTurnCount = previousTurnCountRef.current;
const nextTurnCount = turnWindowModel.turnCount;
if (previousTurnCount === nextTurnCount) {
@@ -176,10 +165,114 @@ export const useChatTimelineController = ({
previousTurnCountRef.current = nextTurnCount;
}, [turnWindowModel.turnCount]);
const resolvePendingRenderWaiters = React.useCallback(() => {
const resolvers = pendingRenderResolversRef.current;
if (resolvers.length === 0) {
return;
}
pendingRenderResolversRef.current = [];
resolvers.forEach((resolve) => resolve());
}, []);
const waitForNextRenderCommit = React.useCallback((): Promise<void> => {
return new Promise<void>((resolve) => {
pendingRenderResolversRef.current.push(resolve);
});
}, []);
const resolvePendingScrollRequest = React.useCallback((value: boolean) => {
const pending = pendingScrollRequestRef.current;
if (!pending) {
return;
}
pendingScrollRequestRef.current = null;
pending.resolve(value);
}, []);
const attemptPendingScrollRequest = React.useCallback(() => {
const pending = pendingScrollRequestRef.current;
if (!pending) {
return;
}
if (pending.sessionId !== sessionIdRef.current) {
resolvePendingScrollRequest(false);
return;
}
const didScroll = pending.kind === 'turn'
? (messageListRef.current?.scrollToTurnId(pending.id, { behavior: pending.behavior }) ?? false)
: (messageListRef.current?.scrollToMessageId(pending.id, { behavior: pending.behavior }) ?? false);
if (didScroll) {
if (pending.turnId) {
setActiveTurnId(pending.turnId);
}
resolvePendingScrollRequest(true);
return;
}
const targetIndex = pending.kind === 'turn'
? turnModelRef.current.turnIndexById.get(pending.id)
: turnModelRef.current.messageToTurnIndex.get(pending.id);
if (typeof targetIndex === 'number' && targetIndex >= turnStartRef.current) {
resolvePendingScrollRequest(false);
}
}, [messageListRef, resolvePendingScrollRequest]);
React.useEffect(() => {
return () => {
resolvePendingRenderWaiters();
resolvePendingScrollRequest(false);
};
}, [resolvePendingRenderWaiters, resolvePendingScrollRequest]);
const renderedMessages = React.useMemo(() => {
return windowMessagesByTurn(messages, turnWindowModel, turnStart);
}, [messages, turnStart, turnWindowModel]);
React.useLayoutEffect(() => {
resolvePendingRenderWaiters();
attemptPendingScrollRequest();
}, [attemptPendingScrollRequest, renderedMessages, resolvePendingRenderWaiters, turnStart]);
// --- Synchronous scroll compensation for load-more / reveal ---
// fetchOlderHistory and revealBufferedTurns store a snapshot here
// before triggering the state change. useLayoutEffect consumes it
// after React commits new DOM — before the browser paints.
const prePrependScrollRef = React.useRef<{
height: number;
top: number;
anchor: ViewportAnchor | null;
} | null>(null);
React.useLayoutEffect(() => {
const snap = prePrependScrollRef.current;
const container = scrollRef.current;
if (!snap || !container) return;
prePrependScrollRef.current = null;
// Try anchor-based restoration first (pixel-perfect)
if (snap.anchor) {
const anchorEl = container.querySelector<HTMLElement>(
`[data-message-id="${snap.anchor.messageId}"]`,
);
if (anchorEl) {
const containerRect = container.getBoundingClientRect();
const anchorTop = anchorEl.getBoundingClientRect().top - containerRect.top;
container.scrollTop += anchorTop - snap.anchor.offsetTop;
return;
}
}
// Fallback: height-delta compensation
const delta = container.scrollHeight - snap.height;
if (delta > 0) {
container.scrollTop = snap.top + delta;
}
}, [renderedMessages, scrollRef]);
const captureViewportAnchor = React.useCallback((): ViewportAnchor | null => {
return messageListRef.current?.captureViewportAnchor() ?? null;
}, [messageListRef]);
@@ -188,35 +281,19 @@ export const useChatTimelineController = ({
return messageListRef.current?.restoreViewportAnchor(anchor) ?? false;
}, [messageListRef]);
const restoreViewportWithFallback = React.useCallback((input: {
anchor: ViewportAnchor | null;
previousHeight: number | null;
previousTop: number | null;
}) => {
const container = scrollRef.current;
if (input.anchor && restoreViewportAnchor(input.anchor)) {
return;
}
if (!container || input.previousHeight === null || input.previousTop === null) {
return;
}
const heightDelta = container.scrollHeight - input.previousHeight;
if (heightDelta !== 0) {
container.scrollTop = input.previousTop + heightDelta;
}
}, [restoreViewportAnchor, scrollRef]);
const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => {
if (turnStartRef.current <= 0 || pendingRevealWorkRef.current) {
return false;
}
const anchor = captureViewportAnchor();
const container = scrollRef.current;
const previousHeight = container?.scrollHeight ?? null;
const previousTop = container?.scrollTop ?? null;
if (container) {
prePrependScrollRef.current = {
height: container.scrollHeight,
top: container.scrollTop,
anchor: captureViewportAnchor(),
};
}
setPendingRevealWork(true);
setTurnStart((current) => {
@@ -224,15 +301,10 @@ export const useChatTimelineController = ({
return next > 0 ? next : 0;
});
await waitForFrames(1);
restoreViewportWithFallback({
anchor,
previousHeight,
previousTop,
});
await waitForNextRenderCommit();
setPendingRevealWork(false);
return true;
}, [captureViewportAnchor, restoreViewportWithFallback, scrollRef]);
}, [captureViewportAnchor, scrollRef, waitForNextRenderCommit]);
const fetchOlderHistory = React.useCallback(async (input: {
preserveViewport: boolean;
@@ -244,16 +316,22 @@ export const useChatTimelineController = ({
return false;
}
const anchor = input.preserveViewport ? captureViewportAnchor() : null;
const container = scrollRef.current;
const previousHeight = input.preserveViewport ? (container?.scrollHeight ?? null) : null;
const previousTop = input.preserveViewport ? (container?.scrollTop ?? null) : null;
const beforeMessages = messagesRef.current;
const beforeMessageCount = beforeMessages.length;
const beforeOldestMessageId = beforeMessages[0]?.info?.id ?? null;
const beforeLimit = historyMetaRef.current?.limit ?? getMemoryLimits().HISTORICAL_MESSAGES;
setPendingRevealWork(true);
// Store scroll snapshot BEFORE the fetch so useLayoutEffect can
// compensate synchronously when React commits the new messages.
if (input.preserveViewport && container) {
prePrependScrollRef.current = {
height: container.scrollHeight,
top: container.scrollTop,
anchor: captureViewportAnchor(),
};
}
setIsLoadingOlder(true);
try {
@@ -274,20 +352,11 @@ export const useChatTimelineController = ({
&& typeof afterOldestMessageId === 'string'
&& beforeOldestMessageId !== afterOldestMessageId);
if (input.preserveViewport) {
restoreViewportWithFallback({
anchor,
previousHeight,
previousTop,
});
}
return historyGrew || afterLimit > beforeLimit;
} finally {
setIsLoadingOlder(false);
setPendingRevealWork(false);
}
}, [captureViewportAnchor, loadMoreMessages, restoreViewportWithFallback, scrollRef]);
}, [captureViewportAnchor, loadMoreMessages, scrollRef]);
const loadEarlier = React.useCallback(async () => {
if (await revealBufferedTurns()) {
@@ -319,26 +388,29 @@ export const useChatTimelineController = ({
if (turnIndex < turnStartRef.current) {
setTurnStart(turnIndex);
await waitForFrames(2);
}
const didScroll = messageListRef.current?.scrollToTurnId(turnId, {
behavior: options?.behavior,
}) ?? false;
const result = await new Promise<boolean>((resolve) => {
pendingScrollRequestRef.current = {
sessionId: sessionIdRef.current ?? sessionId ?? '',
kind: 'turn',
id: turnId,
behavior: options?.behavior ?? 'auto',
turnId,
resolve,
};
attemptPendingScrollRequest();
});
if (didScroll) {
setActiveTurnId(turnId);
if (result) {
return true;
}
await waitForFrames(2);
return messageListRef.current?.scrollToTurnId(turnId, {
behavior: options?.behavior,
}) ?? false;
return false;
} finally {
setPendingRevealWork(false);
}
}, [messageListRef, sessionId]);
}, [attemptPendingScrollRequest, sessionId]);
const scrollToMessage = React.useCallback(async (
messageId: string,
@@ -364,44 +436,59 @@ export const useChatTimelineController = ({
if (turnIndex < turnStartRef.current) {
setTurnStart(turnIndex);
await waitForFrames(2);
}
const didScroll = messageListRef.current?.scrollToMessageId(messageId, {
behavior: options?.behavior,
}) ?? false;
const result = await new Promise<boolean>((resolve) => {
pendingScrollRequestRef.current = {
sessionId: sessionIdRef.current ?? sessionId ?? '',
kind: 'message',
id: messageId,
behavior: options?.behavior ?? 'auto',
turnId: turnId ?? null,
resolve,
};
attemptPendingScrollRequest();
});
if (didScroll) {
if (turnId) {
setActiveTurnId(turnId);
}
if (result) {
return true;
}
await waitForFrames(2);
return messageListRef.current?.scrollToMessageId(messageId, {
behavior: options?.behavior,
}) ?? false;
return false;
} finally {
setPendingRevealWork(false);
}
}, [messageListRef, sessionId]);
}, [attemptPendingScrollRequest, sessionId]);
const resumeToBottom = React.useCallback(() => {
const resumeToBottom = React.useCallback(async () => {
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
setTurnStart(nextStart);
setPendingRevealWork(false);
setIsLoadingOlder(false);
prepareForBottomResume({ force: true });
const shouldWaitForRender = nextStart !== turnStartRef.current;
if (shouldWaitForRender) {
setTurnStart(nextStart);
await waitForNextRenderCommit();
}
scrollToBottom({ force: true });
}, [scrollToBottom]);
}, [prepareForBottomResume, scrollToBottom, waitForNextRenderCommit]);
const resumeToBottomInstant = React.useCallback(() => {
const resumeToBottomInstant = React.useCallback(async () => {
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
setTurnStart(nextStart);
setPendingRevealWork(false);
setIsLoadingOlder(false);
scrollToBottom({ instant: true, force: true });
}, [scrollToBottom]);
prepareForBottomResume({ instant: true, force: true });
const shouldWaitForRender = nextStart !== turnStartRef.current;
if (shouldWaitForRender) {
setTurnStart(nextStart);
await waitForNextRenderCommit();
}
scrollToBottom({ instant: true, force: true, followBottom: true });
}, [prepareForBottomResume, scrollToBottom, waitForNextRenderCommit]);
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
setActiveTurnId(turnId);
@@ -80,7 +80,7 @@ interface UseChatTurnNavigationOptions {
export interface ChatTurnNavigation {
scrollToTurnId: (turnId: string, options?: { behavior?: ScrollBehavior; updateHash?: boolean }) => Promise<boolean>;
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior; updateHash?: boolean }) => Promise<boolean>;
scrollByTurnOffset: (offset: number) => Promise<boolean>;
scrollByTurnOffset: (offset: number, options?: { resumePastEnd?: boolean }) => Promise<boolean>;
resumeToLatest: () => void;
}
@@ -133,14 +133,23 @@ export const useChatTurnNavigation = ({
return scrollToMessage(messageId, { behavior: options?.behavior });
}, [scrollToMessage]);
const scrollByTurnOffset = React.useCallback(async (offset: number): Promise<boolean> => {
const target = resolveTurnOffsetTarget(turnIdsRef.current, activeTurnIdRef.current, offset);
const scrollByTurnOffset = React.useCallback(async (
offset: number,
options?: { resumePastEnd?: boolean },
): Promise<boolean> => {
const turnIds = turnIdsRef.current;
const target = resolveTurnOffsetTarget(turnIds, activeTurnIdRef.current, offset);
if (target.kind === 'noop') {
return offset === 0;
}
if (target.kind === 'resume') {
if (options?.resumePastEnd === false) {
const lastTurnId = turnIds[turnIds.length - 1];
return lastTurnId ? scrollToTurnId(lastTurnId, { behavior: 'auto' }) : false;
}
setHash(null);
resumeToBottom();
return true;
@@ -36,6 +36,7 @@ export const useStreamingTextThrottle = ({
}: UseStreamingTextThrottleInput): string => {
const [throttledText, setThrottledText] = React.useState(text);
const latestTextRef = React.useRef(text);
const throttledTextRef = React.useRef(throttledText);
const stateRef = React.useRef<StreamingThrottleState>({
timer: null,
@@ -47,6 +48,10 @@ export const useStreamingTextThrottle = ({
latestTextRef.current = text;
}, [text]);
React.useEffect(() => {
throttledTextRef.current = throttledText;
}, [throttledText]);
React.useEffect(() => {
const state = stateRef.current;
clearTimer(state);
@@ -58,7 +63,8 @@ export const useStreamingTextThrottle = ({
React.useEffect(() => {
const state = stateRef.current;
state.pendingText = text;
const stableText = isStreaming && throttledText.length > text.length ? throttledText : text;
const currentThrottled = throttledTextRef.current;
const stableText = isStreaming && currentThrottled.length > text.length ? currentThrottled : text;
if (!isStreaming) {
clearTimer(state);
@@ -92,7 +98,7 @@ export const useStreamingTextThrottle = ({
return () => {
clearTimer(state);
};
}, [isStreaming, text, throttleMs, throttledText]);
}, [isStreaming, text, throttleMs]);
React.useEffect(() => {
const state = stateRef.current;
@@ -1,9 +1,10 @@
import React from 'react';
import { projectTurnRecords } from '../lib/turns/projectTurnRecords';
import { stabilizeTurnProjection } from '../lib/turns/stabilizeTurnProjection';
import type { ChatMessageEntry, TurnProjectionResult } from '../lib/turns/types';
import type { ChatMessageEntry, TurnProjectionResult, TurnRecord } from '../lib/turns/types';
import { streamPerfMeasure } from '@/stores/utils/streamDebug';
interface UseTurnRecordsOptions {
sessionKey?: string;
showTextJustificationActivity: boolean;
}
@@ -18,33 +19,66 @@ export const useTurnRecords = (
options: UseTurnRecordsOptions,
): TurnRecordsResult => {
const previousProjectionRef = React.useRef<TurnProjectionResult | null>(null);
const staticTurnsRef = React.useRef<TurnRecord[]>([]);
const streamingTurnRef = React.useRef<TurnRecord | undefined>(undefined);
const previousSessionKeyRef = React.useRef<string | undefined>(options.sessionKey);
if (previousSessionKeyRef.current !== options.sessionKey) {
previousSessionKeyRef.current = options.sessionKey;
previousProjectionRef.current = null;
staticTurnsRef.current = [];
streamingTurnRef.current = undefined;
}
React.useEffect(() => {
previousProjectionRef.current = null;
}, [options.showTextJustificationActivity]);
staticTurnsRef.current = [];
streamingTurnRef.current = undefined;
}, [options.sessionKey, options.showTextJustificationActivity]);
const projection = React.useMemo(() => {
const rawProjection = projectTurnRecords(messages, {
previousProjection: previousProjectionRef.current,
showTextJustificationActivity: options.showTextJustificationActivity,
return streamPerfMeasure('ui.turns.projection_ms', () => {
const nextProjection = projectTurnRecords(messages, {
previousProjection: previousProjectionRef.current,
showTextJustificationActivity: options.showTextJustificationActivity,
});
previousProjectionRef.current = nextProjection;
return nextProjection;
});
const stabilizedProjection = stabilizeTurnProjection(rawProjection, previousProjectionRef.current);
previousProjectionRef.current = stabilizedProjection;
return stabilizedProjection;
}, [messages, options.showTextJustificationActivity]);
const staticTurns = React.useMemo(() => {
if (projection.turns.length <= 1) {
return [];
const nextStatic = projection.turns.length <= 1
? []
: projection.turns.slice(0, -1);
const previousStatic = staticTurnsRef.current;
if (previousStatic.length === nextStatic.length) {
let isSame = true;
for (let index = 0; index < nextStatic.length; index += 1) {
if (previousStatic[index] !== nextStatic[index]) {
isSame = false;
break;
}
}
if (isSame) {
return previousStatic;
}
}
return projection.turns.slice(0, -1);
staticTurnsRef.current = nextStatic;
return nextStatic;
}, [projection.turns]);
const streamingTurn = React.useMemo(() => {
if (projection.turns.length === 0) {
return undefined;
const nextStreamingTurn = projection.turns.length === 0
? undefined
: projection.turns[projection.turns.length - 1];
if (streamingTurnRef.current === nextStreamingTurn) {
return streamingTurnRef.current;
}
return projection.turns[projection.turns.length - 1];
streamingTurnRef.current = nextStreamingTurn;
return nextStreamingTurn;
}, [projection.turns]);
return {
@@ -13,11 +13,6 @@ export const collectVisibleSessionIdsForBlockingRequests = (
const current = sessions.find((session) => session.id === currentSessionId);
if (!current) return [currentSessionId];
// Opencode parity: when viewing a child session, permission/question prompts are handled in parent thread.
if (current.parentID) {
return [];
}
const childrenByParent = new Map<string, string[]>();
for (const session of sessions) {
if (!session.parentID) {
@@ -1,4 +1,4 @@
import type { SessionMemoryState } from '@/stores/types/sessionTypes';
import type { SessionMemoryState } from '@/sync/viewport-store';
export interface TurnHistorySignalsInput {
memoryState: SessionMemoryState | null;
@@ -36,19 +36,6 @@ const getMessageFinish = (message: ChatMessageEntry): string | undefined => {
return typeof finish === 'string' ? finish : undefined;
};
const isAssistantMessageCompleted = (message: ChatMessageEntry): boolean => {
const info = message.info as { time?: { completed?: unknown }; status?: unknown };
const completed = info.time?.completed;
const status = info.status;
if (typeof completed !== 'number' || completed <= 0) {
return false;
}
if (typeof status === 'string') {
return status === 'completed';
}
return true;
};
const buildTurnPartRecord = (
turnId: string,
messageId: string,
@@ -69,6 +56,7 @@ interface ProjectActivityInput {
turnId: string;
assistantMessages: ChatMessageEntry[];
summarySourceMessageId?: string;
summarySourcePartId?: string;
showTextJustificationActivity: boolean;
}
@@ -84,35 +72,42 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
let hasTools = false;
let hasReasoning = false;
input.assistantMessages.forEach((message) => {
message.parts.forEach((part) => {
if (part.type === 'tool') {
hasTools = true;
return;
}
if (part.type === 'reasoning' && getPartText(part)) {
hasReasoning = true;
}
});
});
const taskMessageById = new Map<string, string>();
const taskOrder: string[] = [];
const partsByAfterTool = new Map<string | null, TurnActivityRecord[]>();
let currentAfterToolPartId: string | null = null;
input.assistantMessages.forEach((message) => {
const messageCompleted = isAssistantMessageCompleted(message);
const finish = getMessageFinish(message);
const messageHasTool = message.parts.some((part) => part.type === 'tool');
message.parts.forEach((part, partIndex) => {
const isTool = part.type === 'tool';
if (isTool) {
hasTools = true;
}
const text = part.type === 'reasoning' || part.type === 'text'
? getPartText(part)
: undefined;
if (part.type === 'reasoning' && text) {
hasReasoning = true;
}
const partId = part.id ?? `${message.info.id}-part-${partIndex}-${part.type}`;
const toolName = isTool
? (part as { tool?: unknown }).tool
: undefined;
const standaloneTool = isTool && isStandaloneTool(toolName);
if (standaloneTool) {
const toolPartId = part.id ?? `${message.info.id}-part-${partIndex}-${part.type}`;
const toolPartId = partId;
if (!taskMessageById.has(toolPartId)) {
taskMessageById.set(toolPartId, message.info.id);
taskOrder.push(toolPartId);
@@ -120,6 +115,12 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
currentAfterToolPartId = toolPartId;
}
const isConfirmedSummaryText = part.type === 'text'
&& typeof text === 'string'
&& finish === 'stop'
&& input.summarySourceMessageId === message.info.id
&& input.summarySourcePartId === partId;
let kind: TurnActivityRecord['kind'] | null = null;
if (isTool) {
kind = 'tool';
@@ -130,10 +131,9 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
} else if (
input.showTextJustificationActivity
&& part.type === 'text'
&& messageCompleted
&& typeof finish === 'string'
&& finish !== 'stop'
&& text
&& !isConfirmedSummaryText
&& (messageHasTool || (typeof finish === 'string' && finish !== 'stop'))
) {
kind = 'justification';
}
@@ -171,16 +171,11 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
});
let firstWithAny: string | undefined;
let cumulative = 0;
for (const message of input.assistantMessages) {
const count = countByMessage.get(message.info.id) ?? 0;
if (count > 0 && !firstWithAny) {
firstWithAny = message.info.id;
}
cumulative += count;
if (cumulative >= 2) {
return message.info.id;
}
}
return firstWithAny;
@@ -32,99 +32,6 @@ const getMessageCompletedAt = (message: ChatMessageEntry): number | undefined =>
return typeof completed === 'number' ? completed : undefined;
};
const getMessageFinish = (message: ChatMessageEntry): string | undefined => {
const finish = (message.info as { finish?: unknown }).finish;
return typeof finish === 'string' ? finish : undefined;
};
const getMessageStatus = (message: ChatMessageEntry): string | undefined => {
const status = (message.info as { status?: unknown }).status;
return typeof status === 'string' ? status : undefined;
};
const getPartText = (part: ChatMessageEntry['parts'][number]): string | undefined => {
const text = (part as { text?: unknown }).text;
if (typeof text === 'string') {
return text;
}
const content = (part as { content?: unknown }).content;
return typeof content === 'string' ? content : undefined;
};
const arePartsEquivalentForReuse = (
previousPart: ChatMessageEntry['parts'][number],
nextPart: ChatMessageEntry['parts'][number],
): boolean => {
if (previousPart === nextPart) {
return true;
}
if (previousPart.type !== nextPart.type) {
return false;
}
if (previousPart.id && nextPart.id && previousPart.id !== nextPart.id) {
return false;
}
if (previousPart.type === 'text' || previousPart.type === 'reasoning') {
return getPartText(previousPart) === getPartText(nextPart);
}
if (previousPart.type === 'tool') {
const previousTool = previousPart as {
tool?: unknown;
callID?: unknown;
state?: { status?: unknown };
};
const nextTool = nextPart as {
tool?: unknown;
callID?: unknown;
state?: { status?: unknown };
};
return previousTool.tool === nextTool.tool
&& previousTool.callID === nextTool.callID
&& previousTool.state?.status === nextTool.state?.status;
}
return true;
};
const areMessagesEquivalentForReuse = (previousMessage: ChatMessageEntry, nextMessage: ChatMessageEntry): boolean => {
if (previousMessage === nextMessage) {
return true;
}
if (previousMessage.info.id !== nextMessage.info.id) {
return false;
}
if (getMessageCompletedAt(previousMessage) !== getMessageCompletedAt(nextMessage)) {
return false;
}
if (getMessageFinish(previousMessage) !== getMessageFinish(nextMessage)) {
return false;
}
if (getMessageStatus(previousMessage) !== getMessageStatus(nextMessage)) {
return false;
}
if (previousMessage.parts.length !== nextMessage.parts.length) {
return false;
}
for (let index = 0; index < previousMessage.parts.length; index += 1) {
if (!arePartsEquivalentForReuse(previousMessage.parts[index], nextMessage.parts[index])) {
return false;
}
}
return true;
};
const getUserSummaryBody = (message: ChatMessageEntry): string | undefined => {
const summaryBody = (message.info as { summary?: { body?: unknown } | null | undefined })?.summary?.body;
if (typeof summaryBody !== 'string') {
@@ -194,7 +101,6 @@ export const projectTurnRecords = (
const turns: TurnRecord[] = [];
const turnByUserId = new Map<string, TurnRecord>();
const previousTurnsById = new Map((effectiveOptions.previousProjection?.turns ?? []).map((turn) => [turn.turnId, turn]));
const groupedMessageIds = new Set<string>();
let currentTurn: TurnRecord | undefined;
@@ -254,43 +160,6 @@ export const projectTurnRecords = (
});
turns.forEach((turn) => {
const previousTurn = previousTurnsById.get(turn.turnId);
const canReuseComputed = (() => {
if (!previousTurn) {
return false;
}
if (previousTurn.stream.isStreaming) {
return false;
}
if (!areMessagesEquivalentForReuse(previousTurn.userMessage, turn.userMessage)) {
return false;
}
if (previousTurn.assistantMessages.length !== turn.assistantMessages.length) {
return false;
}
for (let index = 0; index < turn.assistantMessages.length; index += 1) {
if (!areMessagesEquivalentForReuse(previousTurn.assistantMessages[index], turn.assistantMessages[index])) {
return false;
}
}
return true;
})();
if (canReuseComputed && previousTurn) {
turn.summary = previousTurn.summary;
turn.summaryText = previousTurn.summaryText;
turn.diffStats = previousTurn.diffStats;
turn.activityParts = previousTurn.activityParts;
turn.activitySegments = previousTurn.activitySegments;
turn.hasTools = previousTurn.hasTools;
turn.hasReasoning = previousTurn.hasReasoning;
turn.stream = previousTurn.stream;
turn.startedAt = previousTurn.startedAt;
turn.completedAt = previousTurn.completedAt;
turn.durationMs = previousTurn.durationMs;
return;
}
turn.summary = projectTurnSummary(turn.assistantMessages);
turn.summaryText = turn.summary.text ?? getUserSummaryBody(turn.userMessage);
turn.diffStats = projectTurnDiffStats(turn.userMessage);
@@ -299,6 +168,7 @@ export const projectTurnRecords = (
turnId: turn.turnId,
assistantMessages: turn.assistantMessages,
summarySourceMessageId: turn.summary.sourceMessageId,
summarySourcePartId: turn.summary.sourcePartId,
showTextJustificationActivity: effectiveOptions.showTextJustificationActivity,
});
turn.activityParts = activity.activityParts;
@@ -1,6 +1,24 @@
import { projectTurnIndexes } from './projectTurnIndexes';
import type { TurnProjectionResult, TurnRecord } from './types';
const areTurnMessagesReferenceStable = (previousTurn: TurnRecord, nextTurn: TurnRecord): boolean => {
if (previousTurn.userMessage !== nextTurn.userMessage) {
return false;
}
if (previousTurn.assistantMessages.length !== nextTurn.assistantMessages.length) {
return false;
}
for (let index = 0; index < previousTurn.assistantMessages.length; index += 1) {
if (previousTurn.assistantMessages[index] !== nextTurn.assistantMessages[index]) {
return false;
}
}
return true;
};
const buildTurnSignature = (turn: TurnRecord): string => {
const assistantIds = turn.assistantMessageIds.join(',');
return [
@@ -40,6 +58,10 @@ export const stabilizeTurnProjection = (
return turn;
}
if (!areTurnMessagesReferenceStable(previousTurn, turn)) {
return turn;
}
reused = true;
return previousTurn;
});
@@ -20,8 +20,8 @@ export interface StageTurnsResult {
}
const DEFAULT_STAGE_CONFIG: TurnStageConfig = {
init: 1,
batch: 3,
init: 10,
batch: 8,
};
export const getInitialStageCount = (total: number, config: TurnStageConfig): number => {
@@ -105,6 +105,7 @@ export type Turn = Pick<TurnRecord, 'turnId' | 'userMessage' | 'assistantMessage
export interface TurnGroupingContext {
turnId: string;
activityOwnerMessageId?: string;
isFirstAssistantInTurn: boolean;
isLastAssistantInTurn: boolean;
summaryBody?: string;
@@ -23,6 +23,119 @@ export interface TurnWindowModel {
turnCount: number;
}
const getMessageSignature = (message: ChatMessageEntry | undefined): string | null => {
if (!message) return null;
const role = resolveMessageRole(message);
const messageId = typeof message.info?.id === 'string' ? message.info.id : '';
const parentId = resolveParentMessageId(message) ?? '';
return `${messageId}::${role}::${parentId}`;
};
const cloneTurnWindowModel = (model: TurnWindowModel): TurnWindowModel => ({
turnIds: [...model.turnIds],
turnMessageStartIndexes: [...model.turnMessageStartIndexes],
turnIndexById: new Map(model.turnIndexById),
messageToTurnId: new Map(model.messageToTurnId),
messageToTurnIndex: new Map(model.messageToTurnIndex),
turnCount: model.turnCount,
});
export const updateTurnWindowModelIncremental = (
previousModel: TurnWindowModel | null,
previousMessages: ChatMessageEntry[] | null,
nextMessages: ChatMessageEntry[],
): TurnWindowModel | null => {
if (!previousModel || !previousMessages) {
return null;
}
if (previousMessages.length === nextMessages.length) {
let changedIndex = -1;
for (let index = 0; index < nextMessages.length; index += 1) {
if (previousMessages[index] === nextMessages[index]) {
continue;
}
if (changedIndex !== -1) {
return null;
}
changedIndex = index;
}
if (changedIndex === -1) {
return previousModel;
}
if (changedIndex !== nextMessages.length - 1) {
return null;
}
return getMessageSignature(previousMessages[changedIndex]) === getMessageSignature(nextMessages[changedIndex])
? previousModel
: null;
}
if (nextMessages.length !== previousMessages.length + 1) {
return null;
}
for (let index = 0; index < previousMessages.length; index += 1) {
if (previousMessages[index] !== nextMessages[index]) {
return null;
}
}
const nextMessage = nextMessages[nextMessages.length - 1];
if (!nextMessage) {
return null;
}
const role = resolveMessageRole(nextMessage);
const messageId = nextMessage.info.id;
const nextModel = cloneTurnWindowModel(previousModel);
if (role === 'user') {
const nextTurnIndex = nextModel.turnIds.length;
nextModel.turnIds.push(messageId);
nextModel.turnMessageStartIndexes.push(nextMessages.length - 1);
nextModel.turnIndexById.set(messageId, nextTurnIndex);
nextModel.messageToTurnId.set(messageId, messageId);
nextModel.messageToTurnIndex.set(messageId, nextTurnIndex);
nextModel.turnCount = nextModel.turnIds.length;
return nextModel;
}
if (role !== 'assistant') {
const currentTurnIndex = nextModel.turnIds.length - 1;
if (currentTurnIndex < 0) {
return null;
}
const turnId = nextModel.turnIds[currentTurnIndex];
if (!turnId) {
return null;
}
nextModel.messageToTurnId.set(messageId, turnId);
nextModel.messageToTurnIndex.set(messageId, currentTurnIndex);
return nextModel;
}
const parentId = resolveParentMessageId(nextMessage);
const targetTurnIndex = parentId
? nextModel.turnIndexById.get(parentId)
: nextModel.turnIds.length - 1;
if (typeof targetTurnIndex !== 'number' || targetTurnIndex < 0) {
return null;
}
const turnId = nextModel.turnIds[targetTurnIndex];
if (!turnId) {
return null;
}
nextModel.messageToTurnId.set(messageId, turnId);
nextModel.messageToTurnIndex.set(messageId, targetTurnIndex);
return nextModel;
};
export const buildTurnWindowModel = (messages: ChatMessageEntry[]): TurnWindowModel => {
const turnIds: string[] = [];
const turnMessageStartIndexes: number[] = [];
File diff suppressed because it is too large Load Diff
@@ -1,10 +1,20 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions } from '@/sync/sync-context';
import { useInputStore } from '@/sync/input-store';
import { useUIStore } from '@/stores/useUIStore';
import { RiChatNewLine, RiAddLine, RiFileCopyLine } from '@remixicon/react';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { RiBookletLine, RiChatNewLine, RiAddLine, RiFileCopyLine, RiLoader4Line } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { copyTextToClipboard } from '@/lib/clipboard';
import { toast } from '@/components/ui';
import { getProjectNotesAndTodos, saveProjectNotesAndTodos } from '@/lib/openchamberConfig';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { summarizeText } from '@/lib/voice/summarize';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
interface TextSelectionMenuProps {
containerRef: React.RefObject<HTMLElement | null>;
@@ -22,6 +32,17 @@ interface SelectionPayload {
rect: DOMRect;
}
const appendDistilledInsightToNotes = (existingNotes: string, insight: string): string => {
const trimmedInsight = insight.trim().replace(/^[-*+]\s+/, '');
if (!trimmedInsight) {
return existingNotes;
}
const trimmedNotes = existingNotes.trimEnd();
return trimmedNotes ? `${trimmedNotes}\n${trimmedInsight}` : trimmedInsight;
};
const DESKTOP_MENU_SIDE_MARGIN_PX = 8;
const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280;
const BLOCK_TAGS = new Set([
@@ -186,19 +207,26 @@ const rangeToMarkdown = (range: Range, plainText: string): string => {
};
export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerRef }) => {
const { t } = useI18n();
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
const [selectedText, setSelectedText] = React.useState('');
const [selectedTextMarkdown, setSelectedTextMarkdown] = React.useState('');
const [isDragging, setIsDragging] = React.useState(false);
const isDraggingRef = React.useRef(false);
const [isOpening, setIsOpening] = React.useState(false);
const [isAddingToNotes, setIsAddingToNotes] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
const menuWidthRef = React.useRef(DESKTOP_MENU_FALLBACK_WIDTH_PX);
const pendingSelectionRef = React.useRef<SelectionPayload | null>(null);
const openRafRef = React.useRef<number | null>(null);
const isMenuVisibleRef = React.useRef(false);
const createSession = useSessionStore((state) => state.createSession);
const setPendingInputText = useSessionStore((state) => state.setPendingInputText);
const createSession = useSessionUIStore((state) => state.createSession);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
const isMobile = useUIStore((state) => state.isMobile);
const projects = useProjectsStore((state) => state.projects);
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const effectiveDirectory = useEffectiveDirectory();
const sessions = useSessions();
React.useEffect(() => {
isMenuVisibleRef.current = position.show;
@@ -323,7 +351,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const container = containerRef.current;
if (!selection || !container) {
if (!isDragging) {
if (!isDraggingRef.current) {
hideMenu();
}
return;
@@ -333,7 +361,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
// Only show if we have text and the selection is within our container
if (!text) {
if (!isDragging) {
if (!isDraggingRef.current) {
hideMenu();
}
return;
@@ -343,7 +371,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const range = selection.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) {
if (!isDragging) {
if (!isDraggingRef.current) {
hideMenu();
}
return;
@@ -360,10 +388,10 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
};
// Only show menu if we're not currently dragging
if (!isDragging) {
if (!isDraggingRef.current) {
showMenu();
}
}, [containerRef, hideMenu, showMenu, isDragging]);
}, [containerRef, hideMenu, showMenu]);
React.useEffect(() => {
const container = containerRef.current;
@@ -371,13 +399,13 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
// Track when dragging starts
const handleMouseDown = () => {
setIsDragging(true);
isDraggingRef.current = true;
hideMenu();
};
// Track when dragging stops
const handleMouseUp = () => {
setIsDragging(false);
isDraggingRef.current = false;
// Check if we have a pending selection to show
if (pendingSelectionRef.current) {
// Small delay to ensure selection is finalized
@@ -455,6 +483,59 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
window.getSelection()?.removeAllRanges();
}, [selectedText, hideMenu]);
const currentSession = React.useMemo(() => {
if (!currentSessionId) {
return null;
}
return sessions.find((session) => session.id === currentSessionId) ?? null;
}, [currentSessionId, sessions]);
const currentProjectRef = React.useMemo(() => {
const directory = effectiveDirectory
?? (typeof currentSession?.directory === 'string' ? currentSession.directory : '');
const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory);
return resolved ? { id: resolved.id, path: resolved.path } : null;
}, [availableWorktreesByProject, currentSession?.directory, effectiveDirectory, projects]);
const handleAddToNotes = React.useCallback(async () => {
if (!selectedText || !currentProjectRef) {
if (!currentProjectRef) {
toast.error(t('chat.textSelection.toast.noProject'));
}
return;
}
try {
setIsAddingToNotes(true);
const distilledInsight = await summarizeText(selectedText, {
threshold: 0,
maxLength: 100,
mode: 'note',
});
const projectData = await getProjectNotesAndTodos(currentProjectRef);
const nextNotes = appendDistilledInsightToNotes(projectData.notes, distilledInsight);
const saved = await saveProjectNotesAndTodos(currentProjectRef, {
notes: nextNotes,
todos: projectData.todos,
});
if (!saved) {
toast.error(t('chat.textSelection.toast.addToNotesFailed'));
return;
}
window.dispatchEvent(new CustomEvent('openchamber:project-notes-updated', {
detail: { projectId: currentProjectRef.id },
}));
toast.success(t('chat.textSelection.toast.addToNotesSuccess'));
hideMenu();
window.getSelection()?.removeAllRanges();
} catch (error) {
const description = error instanceof Error ? error.message : undefined;
toast.error(t('chat.textSelection.toast.addToNotesFailed'), description ? { description } : undefined);
} finally {
setIsAddingToNotes(false);
}
}, [currentProjectRef, hideMenu, selectedText, t]);
if (!position.show) return null;
// Mobile: Show as a bar at the bottom of the screen, above the keyboard
@@ -463,64 +544,85 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
<div
ref={menuRef}
className={cn(
'fixed left-0 right-0 bottom-0 z-50',
'flex items-center justify-center gap-4',
'bg-[var(--surface-elevated)] border-t border-[var(--interactive-border)]',
'px-3 py-2',
'fixed left-3 right-3 bottom-0 z-50 mx-auto max-w-[420px]',
'rounded-2xl border border-[var(--interactive-border)]',
'bg-[var(--surface-elevated)] p-2 shadow-lg',
'safe-area-bottom',
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
)}
style={{
paddingBottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))',
backdropFilter: 'blur(28px)',
WebkitBackdropFilter: 'blur(28px)',
bottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))',
}}
>
<button
onClick={handleAddToChat}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg',
'text-sm font-medium',
'bg-[var(--primary-base)] text-[var(--primary-foreground)]',
'active:opacity-80',
'transition-opacity duration-150'
)}
type="button"
>
<RiAddLine className="h-5 w-5" />
<span>Add to chat</span>
</button>
<button
onClick={handleCreateNewSession}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg',
'text-sm font-medium',
'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]',
'active:opacity-80',
'transition-opacity duration-150'
)}
type="button"
>
<RiChatNewLine className="h-5 w-5" />
<span>New session</span>
</button>
<button
onClick={handleCopy}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg',
'text-sm font-medium',
'bg-[var(--surface-muted)] text-[var(--surface-foreground)]',
'active:opacity-80',
'transition-opacity duration-150'
)}
type="button"
>
<RiFileCopyLine className="h-5 w-5" />
<span>Copy</span>
</button>
<div className="grid grid-cols-2 gap-2">
<button
onClick={handleAddToChat}
className={cn(
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
'text-sm font-medium leading-tight',
'bg-[var(--primary-base)] text-[var(--primary-foreground)]',
'active:opacity-80',
'transition-opacity duration-150'
)}
title={t('chat.textSelection.title.addToCurrentChat')}
type="button"
>
<RiAddLine className="h-5 w-5 flex-shrink-0" />
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.addToChat')}</span>
</button>
<button
onClick={handleCreateNewSession}
className={cn(
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
'text-sm font-medium leading-tight',
'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]',
'active:opacity-80',
'transition-opacity duration-150'
)}
title={t('chat.textSelection.title.newSessionWithSelection')}
type="button"
>
<RiChatNewLine className="h-5 w-5 flex-shrink-0" />
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.newSession')}</span>
</button>
<button
onClick={handleCopy}
className={cn(
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
'text-sm font-medium leading-tight',
'bg-[var(--surface-muted)] text-[var(--surface-foreground)]',
'active:opacity-80',
'transition-opacity duration-150'
)}
title={t('chat.textSelection.actions.copy')}
type="button"
>
<RiFileCopyLine className="h-5 w-5 flex-shrink-0" />
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.copy')}</span>
</button>
{!isVSCodeRuntime() ? (
<button
onClick={handleAddToNotes}
disabled={isAddingToNotes}
className={cn(
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
'text-sm font-medium leading-tight',
'bg-[var(--surface-muted)] text-[var(--surface-foreground)]',
'active:opacity-80 disabled:opacity-60 disabled:cursor-not-allowed',
'transition-opacity duration-150'
)}
title={t('chat.textSelection.title.saveInsightToNotes')}
type="button"
>
{isAddingToNotes ? <RiLoader4Line className="h-5 w-5 flex-shrink-0 animate-spin" /> : <RiBookletLine className="h-5 w-5 flex-shrink-0" />}
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.addToNotes')}</span>
</button>
) : null}
</div>
</div>,
document.body
);
@@ -546,10 +648,6 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
)}
style={{
backdropFilter: 'blur(28px)',
WebkitBackdropFilter: 'blur(28px)',
}}
>
<button
onClick={handleAddToChat}
@@ -560,11 +658,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
)}
title="Add to current chat"
title={t('chat.textSelection.title.addToCurrentChat')}
type="button"
>
<RiAddLine className="h-4 w-4" />
<span className="whitespace-nowrap">Add to chat</span>
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToChat')}</span>
</button>
<div className="w-px h-4 bg-[var(--interactive-border)]" />
@@ -578,12 +676,35 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
)}
title="Create new session with selection"
title={t('chat.textSelection.title.newSessionWithSelection')}
type="button"
>
<RiChatNewLine className="h-4 w-4" />
<span className="whitespace-nowrap">New session</span>
<span className="whitespace-nowrap">{t('chat.textSelection.actions.newSession')}</span>
</button>
{!isVSCodeRuntime() ? (
<>
<div className="w-px h-4 bg-[var(--interactive-border)]" />
<button
onClick={handleAddToNotes}
disabled={isAddingToNotes}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md',
'text-sm font-medium',
'text-[var(--surface-foreground)]',
'hover:bg-[var(--interactive-hover)] disabled:opacity-60 disabled:cursor-not-allowed',
'transition-colors duration-150'
)}
title={t('chat.textSelection.title.saveInsightToNotes')}
type="button"
>
{isAddingToNotes ? <RiLoader4Line className="h-4 w-4 animate-spin" /> : <RiBookletLine className="h-4 w-4" />}
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToNotes')}</span>
</button>
</>
) : null}
</div>
</div>,
document.body
@@ -20,10 +20,13 @@ import {
renderWebSearchOutput,
formatInputForDisplay,
parseReadToolOutput,
tryParseJsonOutput,
} from './toolRenderers';
import type { ToolPopupContent, DiffViewMode } from './types';
import { DiffViewToggle } from './DiffViewToggle';
import { VirtualizedCodeBlock, type CodeLine } from './parts/VirtualizedCodeBlock';
import { JsonTreeView } from '@/components/ui/JsonTreeView';
import { useI18n } from '@/lib/i18n';
interface ToolOutputDialogProps {
popup: ToolPopupContent;
@@ -92,6 +95,10 @@ const MERMAID_DIALOG_HEADER_HEIGHT = 40;
const MERMAID_ASPECT_RETRY_DELAY_MS = 120;
const MERMAID_ASPECT_MAX_RETRIES = 3;
const DIALOG_CODE_TAG_PROPS = { style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } };
const MERMAID_CONTROLS = { download: false, copy: false, fullscreen: false, panZoom: true };
type PierreThemeConfig = {
theme: { light: string; dark: string };
themeType: 'light' | 'dark';
@@ -300,6 +307,7 @@ const ImagePreviewDialog: React.FC<{
onOpenChange: (open: boolean) => void;
isMobile: boolean;
}> = ({ popup, onOpenChange, isMobile }) => {
const { t } = useI18n();
const gallery = React.useMemo(() => {
const baseImage = popup.image;
if (!baseImage) return [] as Array<{ url: string; mimeType?: string; filename?: string; size?: number }>;
@@ -418,7 +426,7 @@ const ImagePreviewDialog: React.FC<{
<div
aria-hidden="true"
className={cn(
'absolute inset-0 bg-black/25 backdrop-blur-md',
'absolute inset-0 bg-black/40',
isTransitioning && 'transition-opacity duration-150 ease-out',
isVisible ? 'opacity-100' : 'opacity-0'
)}
@@ -431,8 +439,8 @@ const ImagePreviewDialog: React.FC<{
type="button"
onMouseDown={(event) => event.stopPropagation()}
onClick={showPrevious}
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 h-10 w-10 flex items-center justify-center rounded-full bg-black/25 text-foreground/90 backdrop-blur-sm hover:bg-black/35 focus:outline-none focus:ring-2 focus:ring-primary/60"
aria-label="Previous image"
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 h-10 w-10 flex items-center justify-center rounded-full bg-black/40 text-foreground/90 hover:bg-black/55 focus:outline-none focus:ring-2 focus:ring-primary/60"
aria-label={t('chat.toolOutputDialog.image.previousAria')}
>
<RiArrowLeftSLine className="h-6 w-6" />
</button>
@@ -440,8 +448,8 @@ const ImagePreviewDialog: React.FC<{
type="button"
onMouseDown={(event) => event.stopPropagation()}
onClick={showNext}
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 h-10 w-10 flex items-center justify-center rounded-full bg-black/25 text-foreground/90 backdrop-blur-sm hover:bg-black/35 focus:outline-none focus:ring-2 focus:ring-primary/60"
aria-label="Next image"
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 h-10 w-10 flex items-center justify-center rounded-full bg-black/40 text-foreground/90 hover:bg-black/55 focus:outline-none focus:ring-2 focus:ring-primary/60"
aria-label={t('chat.toolOutputDialog.image.nextAria')}
>
<RiArrowRightSLine className="h-6 w-6" />
</button>
@@ -470,7 +478,7 @@ const ImagePreviewDialog: React.FC<{
type="button"
className="h-8 w-8 flex items-center justify-center rounded-lg text-muted-foreground/80 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-primary/60"
onClick={() => onOpenChange(false)}
aria-label="Close image preview"
aria-label={t('chat.toolOutputDialog.image.closeAria')}
>
<RiCloseLine className="h-4 w-4" />
</button>
@@ -630,6 +638,7 @@ const MermaidPreviewDialog: React.FC<{
onOpenChange: (open: boolean) => void;
isMobile: boolean;
}> = ({ popup, onOpenChange, isMobile }) => {
const { t } = useI18n();
const [source, setSource] = React.useState<string>(popup.mermaid?.source || '');
const [status, setStatus] = React.useState<'idle' | 'loading' | 'ready' | 'error'>(popup.mermaid?.source ? 'ready' : 'idle');
const [errorMessage, setErrorMessage] = React.useState<string>('');
@@ -705,7 +714,7 @@ const MermaidPreviewDialog: React.FC<{
const target = popup.mermaid;
if (!target?.url) {
setStatus('error');
setErrorMessage('Missing Mermaid source URL.');
setErrorMessage(t('chat.toolOutputDialog.mermaid.missingSource'));
return;
}
@@ -771,9 +780,9 @@ const MermaidPreviewDialog: React.FC<{
return;
}
setStatus('error');
setErrorMessage(error instanceof Error ? error.message : 'Unable to load Mermaid diagram.');
setErrorMessage(error instanceof Error ? error.message : t('chat.toolOutputDialog.mermaid.loadFailed'));
});
}, [decodeDataUrl, normalizeFilePath, popup.mermaid]);
}, [decodeDataUrl, normalizeFilePath, popup.mermaid, t]);
React.useEffect(() => {
if (!popup.open || !popup.mermaid) {
@@ -889,7 +898,7 @@ const MermaidPreviewDialog: React.FC<{
<div
aria-hidden="true"
className={cn(
'absolute inset-0 bg-black/25 backdrop-blur-md',
'absolute inset-0 bg-black/40',
isTransitioning && 'transition-opacity duration-150 ease-out',
isVisible ? 'opacity-100' : 'opacity-0'
)}
@@ -916,7 +925,7 @@ const MermaidPreviewDialog: React.FC<{
type="button"
className="h-8 w-8 flex items-center justify-center rounded-lg text-muted-foreground/80 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-primary/60"
onClick={() => onOpenChange(false)}
aria-label="Close diagram preview"
aria-label={t('chat.toolOutputDialog.mermaid.closeAria')}
>
<RiCloseLine className="h-4 w-4" />
</button>
@@ -929,14 +938,14 @@ const MermaidPreviewDialog: React.FC<{
{status === 'loading' && (
<div className="h-full min-h-28 flex items-center justify-center gap-2 text-muted-foreground typography-meta">
<RiLoader4Line className="h-4 w-4 animate-spin" />
<span>Loading diagram...</span>
<span>{t('chat.toolOutputDialog.mermaid.loading')}</span>
</div>
)}
{status === 'error' && (
<div className="rounded-xl border border-border/30 bg-muted/20 p-3 space-y-3">
<p className="typography-markdown" style={{ color: 'var(--status-error)' }}>
{errorMessage || 'Unable to render Mermaid diagram.'}
{errorMessage || t('chat.toolOutputDialog.mermaid.renderFailed')}
</p>
<button
type="button"
@@ -949,7 +958,7 @@ const MermaidPreviewDialog: React.FC<{
color: 'var(--surface-foreground)',
}}
>
Retry
{t('chat.toolOutputDialog.mermaid.retry')}
</button>
</div>
)}
@@ -960,13 +969,8 @@ const MermaidPreviewDialog: React.FC<{
content={mermaidMarkdown}
variant="tool"
allowMermaidWheelZoom
className="streamdown-mermaid-fullscreen h-full [&_[data-streamdown='mermaid-block']_button]:hidden"
mermaidControls={{
download: false,
copy: false,
fullscreen: false,
panZoom: true,
}}
className="markdown-mermaid-fullscreen h-full [&_[data-markdown='mermaid-block']_button]:hidden"
mermaidControls={MERMAID_CONTROLS}
/>
</div>
)}
@@ -981,6 +985,7 @@ const MermaidPreviewDialog: React.FC<{
};
const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange, syntaxTheme, isMobile }) => {
const { t } = useI18n();
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>('unified');
const pierreThemeConfig = usePierreThemeConfig();
@@ -1053,7 +1058,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
language="bash"
PreTag="div"
customStyle={toolDisplayStyles.getPopupStyles()}
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
codeTagProps={DIALOG_CODE_TAG_PROPS}
wrapLongLines
>
{getInputValue('command')!}
@@ -1110,14 +1115,20 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
if (tool === 'todowrite' || tool === 'todoread') {
return (
renderTodoOutput(popup.content) || (
renderTodoOutput(popup.content, {
total: t('chat.todo.total'),
inProgress: t('chat.todo.inProgress'),
pending: t('chat.todo.pending'),
completed: t('chat.todo.completed'),
cancelled: t('chat.todo.cancelled'),
}) || (
<SyntaxHighlighter
style={syntaxTheme}
language="json"
PreTag="div"
wrapLongLines
customStyle={toolDisplayStyles.getPopupContainerStyles()}
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
codeTagProps={DIALOG_CODE_TAG_PROPS}
>
{popup.content}
</SyntaxHighlighter>
@@ -1172,7 +1183,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
PreTag="div"
wrapLongLines
customStyle={toolDisplayStyles.getPopupContainerStyles()}
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
codeTagProps={DIALOG_CODE_TAG_PROPS}
>
{popup.content}
</SyntaxHighlighter>
@@ -1184,6 +1195,18 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
return <DialogReadContent popup={popup} syntaxTheme={syntaxTheme} pierreThemeConfig={pierreThemeConfig} />;
}
// JSON tree viewer for generic JSON outputs
const jsonResult = popup.content ? tryParseJsonOutput(popup.content) : { data: null, isJson: false };
if (jsonResult.isJson) {
return (
<JsonTreeView
jsonString={popup.content}
initiallyExpandedDepth={3}
maxHeight="70vh"
/>
);
}
return (
<SyntaxHighlighter
style={syntaxTheme}
@@ -1191,7 +1214,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
PreTag="div"
wrapLongLines
customStyle={toolDisplayStyles.getPopupContainerStyles()}
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
codeTagProps={DIALOG_CODE_TAG_PROPS}
>
{popup.content}
</SyntaxHighlighter>
@@ -1200,8 +1223,8 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
</div>
) : (
<div className="p-8 text-muted-foreground typography-ui-header">
<div className="mb-2">Command completed successfully</div>
<div className="typography-meta">No output was produced</div>
<div className="mb-2">{t('chat.toolOutputDialog.commandCompleted')}</div>
<div className="typography-meta">{t('chat.toolOutputDialog.noOutputProduced')}</div>
</div>
)}
</div>
@@ -81,15 +81,16 @@ const buildGitHubAttachmentPart = (text: string): Part | null => {
return null;
};
const shouldKeepSyntheticUserText = (text: string): boolean => {
const shouldKeepSyntheticUserText = (text: string, planModeEnabled: boolean): boolean => {
const trimmed = text.trim();
if (trimmed.startsWith('User has requested to enter plan mode')) return true;
if (trimmed.startsWith('The plan at ')) return true;
if (planModeEnabled && trimmed.startsWith('User has requested to enter plan mode')) return true;
if (planModeEnabled && trimmed.startsWith('The plan at ')) return true;
if (trimmed.startsWith('The following tool was executed by the user')) return true;
return false;
};
export const normalizeUserDisplayParts = (parts: Part[]): Part[] => {
export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEnabled?: boolean }): Part[] => {
const planModeEnabled = options?.planModeEnabled === true;
return parts
.filter((part) => {
const synthetic = (part as { synthetic?: boolean }).synthetic === true;
@@ -101,7 +102,7 @@ export const normalizeUserDisplayParts = (parts: Part[]): Part[] => {
}
const normalizedText = text.trimStart();
return shouldKeepSyntheticUserText(text)
return shouldKeepSyntheticUserText(text, planModeEnabled)
|| normalizedText.startsWith(GITHUB_ISSUE_CONTEXT_PREFIX)
|| normalizedText.startsWith(GITHUB_PR_CONTEXT_PREFIX);
})
@@ -5,11 +5,13 @@ import type { StreamPhase } from '../types';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility';
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
type PartWithText = Part & { text?: string; content?: string; value?: string; time?: { start?: number; end?: number } };
interface AssistantTextPartProps {
part: Part;
sessionId?: string;
messageId: string;
streamPhase: StreamPhase;
chatRenderMode?: 'sorted' | 'live';
@@ -22,6 +24,8 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
streamPhase,
chatRenderMode = 'live',
}) => {
// Use part directly from props — parent provides the latest version from the store.
// No store subscription here to avoid re-render cascade from unrelated delta events.
const partWithText = part as PartWithText;
const rawText = typeof partWithText.text === 'string' ? partWithText.text : '';
const contentText = typeof partWithText.content === 'string' ? partWithText.content : '';
@@ -33,6 +37,11 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
const isCooldownPhase = streamPhase === 'cooldown';
const isStreaming = chatRenderMode === 'live' && (isStreamingPhase || isCooldownPhase);
streamPerfCount('ui.assistant_text_part.render');
if (isStreaming) {
streamPerfCount('ui.assistant_text_part.render.streaming');
}
const throttledTextContent = useStreamingTextThrottle({
text: textContent,
isStreaming,
@@ -45,32 +54,7 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
isStreaming,
});
const lastDisplayLengthRef = React.useRef(0);
React.useEffect(() => {
if (!isStreaming || typeof window === 'undefined') {
lastDisplayLengthRef.current = displayTextContent.length;
return;
}
const debugEnabled = window.localStorage.getItem('openchamber_stream_debug') === '1';
if (!debugEnabled) {
lastDisplayLengthRef.current = displayTextContent.length;
return;
}
if (displayTextContent.length < lastDisplayLengthRef.current) {
console.info('[STREAM-TRACE] render_shrink', {
messageId,
partId: part.id,
rawTextLen: rawText.length,
contentLen: contentText.length,
valueLen: valueText.length,
chosenLen: textContent.length,
throttledLen: throttledTextContent.length,
displayLen: displayTextContent.length,
prevDisplayLen: lastDisplayLengthRef.current,
});
}
lastDisplayLengthRef.current = displayTextContent.length;
}, [contentText.length, displayTextContent.length, isStreaming, messageId, part.id, rawText.length, textContent.length, throttledTextContent.length, valueText.length]);
streamPerfObserve('ui.assistant_text_part.display_len', displayTextContent.length);
const time = partWithText.time;
const isFinalized = Boolean(time && typeof time.end !== 'undefined');
@@ -105,4 +89,4 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
);
};
export default AssistantTextPart;
export default React.memo(AssistantTextPart);
@@ -0,0 +1,25 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface BusyDotsProps {
className?: string;
}
const DOT_DELAYS_MS = [0, 200, 400] as const;
export const BusyDots: React.FC<BusyDotsProps> = ({ className }) => (
<>
{'\u00A0'}
<span className={cn('inline-flex', className)} aria-hidden="true">
{DOT_DELAYS_MS.map((delay) => (
<span
key={delay}
className="animate-busy-pulse"
style={{ animationDelay: `${delay}ms` }}
>
.
</span>
))}
</span>
</>
);
@@ -83,5 +83,5 @@ Why: in current pipeline Perplexity is static/grouped, so `StaticToolRow` is the
- Text: `AssistantTextPart.tsx`, `UserTextPart.tsx`
- Tools: `ToolPart.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx`
- Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx`
- Status/placeholders: `WorkingPlaceholder.tsx`, `GenericStatusSpinner.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`
- Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.tsx`
- Utility renderers: `VirtualizedCodeBlock.tsx`, `MinDurationShineText.tsx`
@@ -1,56 +0,0 @@
import React from 'react';
/**
* Starfield Twinkle a 4×4 grid of tiny dots that flicker
* like stars in a night sky. Each dot has its own random phase
* and duration so the pattern never looks mechanical.
*
* Corners are hidden (same as original) to soften the grid shape.
*/
const COLS = 4;
const ROWS = 4;
const SPACING = 3.2; // viewBox units between centers
const OFFSET = 2.7; // center the grid in 15×15
const DOT_R = 0.7; // small dot radius — star-like
const cornerIndices = new Set([0, 3, 12, 15]);
const stars = Array.from({ length: COLS * ROWS }, (_, i) => ({
id: i,
cx: (i % COLS) * SPACING + OFFSET,
cy: Math.floor(i / COLS) * SPACING + OFFSET,
isCorner: cornerIndices.has(i),
// Each star gets its own rhythm — varying duration + delay
duration: 2.4 + Math.random() * 2.4,
delay: Math.random() * 3.5,
}));
export function GenericStatusSpinner({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 15 15"
data-component="opencode-spinner"
className={className}
fill="var(--foreground)"
aria-hidden="true"
>
{stars.map((star) => (
<circle
key={star.id}
cx={star.cx}
cy={star.cy}
r={DOT_R}
style={
star.isCorner
? { opacity: 0 }
: {
animation: `star-twinkle ${star.duration}s ease-in-out infinite`,
animationDelay: `${star.delay}s`,
}
}
/>
))}
</svg>
);
}
@@ -23,12 +23,14 @@ interface JustificationBlockProps {
part: Part;
messageId: string;
onContentChange?: (reason?: ContentChangeReason) => void;
actions?: React.ReactNode;
}
const JustificationBlock: React.FC<JustificationBlockProps> = ({
part,
messageId,
onContentChange,
actions,
}) => {
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
const partWithText = part as PartWithText;
@@ -49,6 +51,7 @@ const JustificationBlock: React.FC<JustificationBlockProps> = ({
blockId={part.id || `${messageId}-justification`}
time={time}
showDuration={chatRenderMode !== 'sorted'}
actions={actions}
/>
);
};

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