@@ -123,3 +123,79 @@ jobs:
|
|||||||
name: dmg-${{ inputs.macos_version }}-arm64
|
name: dmg-${{ inputs.macos_version }}-arm64
|
||||||
path: artifacts/*.dmg
|
path: artifacts/*.dmg
|
||||||
retention-days: 7
|
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
|
||||||
|
|||||||
@@ -494,11 +494,174 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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:
|
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
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
DISCORD_UPDATE_ROLE_ID: ${{ secrets.DISCORD_UPDATE_ROLE_ID }}
|
||||||
steps:
|
steps:
|
||||||
- name: Publish release
|
- name: Publish release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
@@ -514,11 +677,14 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
VERSION: ${{ needs.create-release.outputs.version }}
|
VERSION: ${{ needs.create-release.outputs.version }}
|
||||||
REPOSITORY: ${{ github.repository }}
|
REPOSITORY: ${{ github.repository }}
|
||||||
|
UPDATE_ROLE_ID: ${{ env.DISCORD_UPDATE_ROLE_ID }}
|
||||||
run: |
|
run: |
|
||||||
node - <<'NODE'
|
node - <<'NODE'
|
||||||
(async () => {
|
(async () => {
|
||||||
const tag = `v${process.env.VERSION}`;
|
const tag = `v${process.env.VERSION}`;
|
||||||
const repo = process.env.REPOSITORY;
|
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}`, {
|
const releaseRes = await fetch(`https://api.github.com/repos/${repo}/releases/tags/${tag}`, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -534,9 +700,18 @@ jobs:
|
|||||||
|
|
||||||
const release = await releaseRes.json();
|
const release = await releaseRes.json();
|
||||||
const description = (release.body || `OpenChamber ${tag} released.`).slice(0, 4096);
|
const description = (release.body || `OpenChamber ${tag} released.`).slice(0, 4096);
|
||||||
|
const mention = updateRoleId ? `<@&${updateRoleId}>` : '';
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
username: 'OpenChamber Releases',
|
username: 'OpenChamber Releases',
|
||||||
|
...(mention ? { content: mention } : {}),
|
||||||
|
...(updateRoleId
|
||||||
|
? {
|
||||||
|
allowed_mentions: {
|
||||||
|
roles: [updateRoleId],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
embeds: [
|
embeds: [
|
||||||
{
|
{
|
||||||
title: release.name || `OpenChamber ${tag}`,
|
title: release.name || `OpenChamber ${tag}`,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ local-dev*
|
|||||||
*.sw?
|
*.sw?
|
||||||
.opencode/plans/*
|
.opencode/plans/*
|
||||||
.hive
|
.hive
|
||||||
|
docs/personal/*
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
build/
|
build/
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,88 +1,130 @@
|
|||||||
# OpenChamber - AI Agent Reference (verified)
|
# OpenChamber - AI Agent Reference (verified)
|
||||||
|
|
||||||
## Core purpose
|
## 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`.
|
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)
|
## 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.
|
- `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.
|
||||||
- Tauri is used only for stable native integrations: menu, dialog (open folder), notifications, updater, deep-links.
|
- `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`)
|
## Tech stack (source of truth: `package.json`, resolved: `bun.lock`)
|
||||||
|
|
||||||
- Runtime/tooling: Bun (`package.json` `packageManager`), Node >=20 (`package.json` `engines`)
|
- Runtime/tooling: Bun (`package.json` `packageManager`), Node >=20 (`package.json` `engines`)
|
||||||
- UI: React, TypeScript, Vite, Tailwind v4
|
- UI: React, TypeScript, Vite, Tailwind v4
|
||||||
- State: Zustand (`packages/ui/src/stores/`)
|
- 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`)
|
- 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/`)
|
- VS Code: extension + webview (`packages/vscode/`)
|
||||||
|
|
||||||
## Monorepo layout
|
## Monorepo layout
|
||||||
|
|
||||||
Workspaces are `packages/*` (see `package.json`).
|
Workspaces are `packages/*` (see `package.json`).
|
||||||
|
|
||||||
- Shared UI: `packages/ui`
|
- Shared UI: `packages/ui`
|
||||||
- Web app + server + CLI: `packages/web`
|
- 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`
|
- VS Code extension: `packages/vscode`
|
||||||
|
|
||||||
## Documentation map
|
## Documentation map
|
||||||
|
|
||||||
Before changing any mapped module, read its module documentation first.
|
Before changing any mapped module, read its module documentation first.
|
||||||
|
|
||||||
### web
|
### web
|
||||||
|
|
||||||
Web runtime and server implementation for OpenChamber.
|
Web runtime and server implementation for OpenChamber.
|
||||||
|
|
||||||
#### lib
|
#### lib
|
||||||
|
|
||||||
Server-side integration modules used by API routes and runtime services.
|
Server-side integration modules used by API routes and runtime services.
|
||||||
|
|
||||||
##### quota
|
##### quota
|
||||||
|
|
||||||
Quota provider registry, dispatch, and provider integrations for usage endpoints.
|
Quota provider registry, dispatch, and provider integrations for usage endpoints.
|
||||||
|
|
||||||
- Module docs: `packages/web/server/lib/quota/DOCUMENTATION.md`
|
- Module docs: `packages/web/server/lib/quota/DOCUMENTATION.md`
|
||||||
|
|
||||||
##### git
|
##### git
|
||||||
|
|
||||||
Git repository operations for the web server runtime.
|
Git repository operations for the web server runtime.
|
||||||
|
|
||||||
- Module docs: `packages/web/server/lib/git/DOCUMENTATION.md`
|
- Module docs: `packages/web/server/lib/git/DOCUMENTATION.md`
|
||||||
|
|
||||||
##### github
|
##### github
|
||||||
|
|
||||||
GitHub authentication, OAuth device flow, Octokit client factory, and repository URL parsing.
|
GitHub authentication, OAuth device flow, Octokit client factory, and repository URL parsing.
|
||||||
|
|
||||||
- Module docs: `packages/web/server/lib/github/DOCUMENTATION.md`
|
- Module docs: `packages/web/server/lib/github/DOCUMENTATION.md`
|
||||||
|
|
||||||
##### opencode
|
##### opencode
|
||||||
|
|
||||||
OpenCode server integration utilities including config management, provider authentication, and UI authentication.
|
OpenCode server integration utilities including config management, provider authentication, and UI authentication.
|
||||||
|
|
||||||
- Module docs: `packages/web/server/lib/opencode/DOCUMENTATION.md`
|
- Module docs: `packages/web/server/lib/opencode/DOCUMENTATION.md`
|
||||||
|
|
||||||
##### notifications
|
##### notifications
|
||||||
|
|
||||||
Notification message preparation utilities for system notifications, including text truncation and optional summarization.
|
Notification message preparation utilities for system notifications, including text truncation and optional summarization.
|
||||||
|
|
||||||
- Module docs: `packages/web/server/lib/notifications/DOCUMENTATION.md`
|
- Module docs: `packages/web/server/lib/notifications/DOCUMENTATION.md`
|
||||||
|
|
||||||
##### terminal
|
##### terminal
|
||||||
|
|
||||||
WebSocket protocol utilities for terminal input handling including message normalization, control frame parsing, and rate limiting.
|
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`
|
- Module docs: `packages/web/server/lib/terminal/DOCUMENTATION.md`
|
||||||
|
|
||||||
##### tts
|
##### tts
|
||||||
|
|
||||||
Server-side text-to-speech services and summarization helpers for `/api/tts/*` endpoints.
|
Server-side text-to-speech services and summarization helpers for `/api/tts/*` endpoints.
|
||||||
|
|
||||||
- Module docs: `packages/web/server/lib/tts/DOCUMENTATION.md`
|
- Module docs: `packages/web/server/lib/tts/DOCUMENTATION.md`
|
||||||
|
|
||||||
##### skills-catalog
|
##### skills-catalog
|
||||||
|
|
||||||
Skills catalog management including discovery, installation, and configuration of agent skill packages.
|
Skills catalog management including discovery, installation, and configuration of agent skill packages.
|
||||||
|
|
||||||
- Module docs: `packages/web/server/lib/skills-catalog/DOCUMENTATION.md`
|
- Module docs: `packages/web/server/lib/skills-catalog/DOCUMENTATION.md`
|
||||||
|
|
||||||
## Build / dev commands (verified)
|
## Build / dev commands (verified)
|
||||||
|
|
||||||
All scripts are in `package.json`.
|
All scripts are in `package.json`.
|
||||||
|
|
||||||
- Validate: `bun run type-check`, `bun run lint`
|
- Validate: `bun run type-check`, `bun run lint`
|
||||||
- Build all: `bun run build`
|
- 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`
|
- VS Code build: `bun run vscode:build`
|
||||||
- Release smoke build: `bun run release:test` (shell script: `scripts/test-release-build.sh`)
|
- Release smoke build: `bun run release:test` (shell script: `scripts/test-release-build.sh`)
|
||||||
|
|
||||||
## Runtime entry points
|
## Runtime entry points
|
||||||
|
|
||||||
- Web bootstrap: `packages/web/src/main.tsx`
|
- Web bootstrap: `packages/web/src/main.tsx`
|
||||||
- Web server: `packages/web/server/index.js`
|
- Web server: `packages/web/server/index.js`
|
||||||
- Web CLI: `packages/web/bin/cli.js` (package bin: `packages/web/package.json`)
|
- 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)
|
- 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)
|
||||||
- Tauri backend: `packages/desktop/src-tauri/src/main.rs`
|
- Desktop (Tauri — legacy): `packages/desktop/src-tauri/src/main.rs`
|
||||||
- VS Code extension host: `packages/vscode/src/extension.ts`
|
- VS Code extension host: `packages/vscode/src/extension.ts`
|
||||||
- VS Code webview bootstrap: `packages/vscode/webview/main.tsx`
|
- VS Code webview bootstrap: `packages/vscode/webview/main.tsx`
|
||||||
|
|
||||||
## OpenCode integration
|
## OpenCode integration
|
||||||
|
|
||||||
- UI client wrapper: `packages/ui/src/lib/opencode/client.ts` (imports `@opencode-ai/sdk/v2`)
|
- UI client wrapper: `packages/ui/src/lib/opencode/client.ts` (imports `@opencode-ai/sdk/v2`)
|
||||||
- SSE hookup: `packages/ui/src/hooks/useEventStream.ts`
|
- SSE hookup: `packages/ui/src/hooks/useEventStream.ts`
|
||||||
- Web server embeds/starts OpenCode server: `packages/web/server/index.js` (`createOpencodeServer`)
|
- 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
|
- 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)
|
## Key UI patterns (reference files)
|
||||||
|
|
||||||
- Settings shell: `packages/ui/src/components/views/SettingsView.tsx`
|
- Settings shell: `packages/ui/src/components/views/SettingsView.tsx`
|
||||||
- Settings shared primitives: `packages/ui/src/components/sections/shared/`
|
- Settings shared primitives: `packages/ui/src/components/sections/shared/`
|
||||||
- Settings sections: `packages/ui/src/components/sections/` (incl `skills/`)
|
- 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`)
|
- Terminal UI: `packages/ui/src/components/terminal/` (uses `ghostty-web`)
|
||||||
|
|
||||||
## External / system integrations (active)
|
## External / system integrations (active)
|
||||||
|
|
||||||
- Git: `packages/ui/src/lib/gitApi.ts`, `packages/web/server/index.js` (`simple-git`)
|
- 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`)
|
- 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/`
|
- Skills catalog: `packages/web/server/lib/skills-catalog/`, UI: `packages/ui/src/components/sections/skills/`
|
||||||
|
|
||||||
## Agent constraints
|
## Agent constraints
|
||||||
|
|
||||||
- Do not modify `../opencode` (separate repo).
|
- Do not modify `../opencode` (separate repo).
|
||||||
- Do not run git/GitHub commands unless explicitly asked.
|
- 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
|
## Development rules
|
||||||
|
|
||||||
- Keep diffs tight; avoid drive-by refactors.
|
- Keep diffs tight; avoid drive-by refactors.
|
||||||
- Backend changes: keep web/desktop/vscode runtimes consistent (if relevant).
|
- Follow local precedent; inspect nearby code before introducing new patterns.
|
||||||
- Follow local precedent; search nearby code first.
|
- Backend changes: keep web, desktop, and VS Code behavior consistent when they share contracts.
|
||||||
- TypeScript: avoid `any`/blind casts; keep ESLint/TS green.
|
- TypeScript: avoid `any`, blind casts, and shape guessing.
|
||||||
- React: prefer function components + hooks; class only when needed (e.g. error boundaries).
|
- React: prefer function components + hooks; use classes only when required.
|
||||||
- Control flow: avoid nested ternaries; prefer early returns + `if/else`/`switch`.
|
- 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/`.
|
- 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.
|
- Shared UI patterns: reuse shared primitives before introducing feature-local markup patterns.
|
||||||
- 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.
|
- Toasts: use the wrapper from `@/components/ui`; do not import `sonner` directly in feature code.
|
||||||
- No new deps unless asked.
|
- 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)
|
## 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.
|
When working on terminal CLI commands, prompts, or output formatting, agents **MUST** study the Clack CLI skill first.
|
||||||
|
|
||||||
**Before starting terminal CLI work:**
|
**Before starting terminal CLI work:**
|
||||||
|
|
||||||
```
|
```
|
||||||
skill({ name: "clack-cli-patterns" })
|
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.
|
When working on any UI components, styling, or visual changes, agents **MUST** study the theme system skill first.
|
||||||
|
|
||||||
**Before starting any UI work:**
|
**Before starting any UI work:**
|
||||||
|
|
||||||
```
|
```
|
||||||
skill({ name: "theme-system" })
|
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.
|
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
|
## Recent changes
|
||||||
|
|
||||||
- Releases + high-level changes: `CHANGELOG.md`
|
- Releases + high-level changes: `CHANGELOG.md`
|
||||||
- Recent commits: `git log --oneline` (latest tags: `v1.4.6`, `v1.4.5`)
|
- Recent commits: `git log --oneline` (latest tags: `v1.4.6`, `v1.4.5`)
|
||||||
|
|||||||
@@ -4,6 +4,106 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [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.
|
- 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).
|
- 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).
|
- Usage: added MiniMax Weekly quota provider support for broader usage tracking coverage (thanks to @nzlov).
|
||||||
|
|
||||||
|
|
||||||
## [1.9.0] - 2026-03-20
|
## [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).
|
- 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).
|
- 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.
|
- Updates/Reliability: unified update-check behavior across runtimes for more consistent update availability checks.
|
||||||
|
|
||||||
|
|
||||||
## [1.8.7] - 2026-03-13
|
## [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`.
|
- 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).
|
- 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).
|
- 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
|
## [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).
|
- 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).
|
- 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).
|
- 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
|
## [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).
|
- 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).
|
- 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).
|
- 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
|
## [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.
|
- 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.
|
- 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).
|
- 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
|
## [1.8.2] - 2026-03-01
|
||||||
|
|
||||||
- Updates: hardened the self-update flow with safer release handling and fallback behavior, reducing failed or stuck updates.
|
- 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).
|
- 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).
|
- Deployment: fixed Docker build/runtime issues for more reliable containerized setups (thanks to @nzlov).
|
||||||
|
|
||||||
|
|
||||||
## [1.8.1] - 2026-02-28
|
## [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.
|
- 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
|
## [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).
|
- 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 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).
|
- Usage: added Ollama Cloud quota provider support for broader usage tracking coverage (thanks to @iamhenry).
|
||||||
|
|
||||||
|
|
||||||
## [1.7.5] - 2026-02-25
|
## [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.
|
- 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: 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.
|
- Web/Mobile: fixed in-app update flow in containerized setups so updates apply correctly.
|
||||||
|
|
||||||
|
|
||||||
## [1.7.4] - 2026-02-24
|
## [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.
|
- 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).
|
- 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).
|
- 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
|
## [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).
|
- 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: 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).
|
- 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
|
## [1.7.2] - 2026-02-20
|
||||||
|
|
||||||
- Chat: question prompts now guide you to unanswered items before submit, making tool-question flows faster.
|
- 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.
|
- 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).
|
- 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
|
## [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.
|
- 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).
|
- 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).
|
- Maintenance: removed deprecated GitHub Actions cloud runtime assets and docs to reduce setup confusion (thanks to @yulia-ivashko).
|
||||||
|
|
||||||
|
|
||||||
## [1.7.0] - 2026-02-17
|
## [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: 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.
|
- 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.
|
- 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.
|
- 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).
|
- 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
|
## [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.**
|
- **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).
|
- 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).
|
- Mobile: fixes for small-screen editor, terminal, and layout overlap issues (thanks to @gsxdsm, @nelsonPires5).
|
||||||
|
|
||||||
|
|
||||||
## [1.6.8] - 2026-02-12
|
## [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.
|
- 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 project selection in opened remote instances.
|
||||||
- Desktop: fixed opened remote instances that use HTTP (helpful for instances under tunneling).
|
- Desktop: fixed opened remote instances that use HTTP (helpful for instances under tunneling).
|
||||||
|
|
||||||
|
|
||||||
## [1.6.7] - 2026-02-10
|
## [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).
|
- 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).
|
- 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).
|
- 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
|
## [1.6.5] - 2026-02-6
|
||||||
|
|
||||||
- Settings: added an OpenCode CLI path override so you can point OpenChamber at a custom/local CLI install.
|
- 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.
|
- UI: added Vitesse Dark and Vitesse Light theme presets.
|
||||||
- Reliability: improved OpenCode binary resolution and HOME-path handling across runtimes for steadier local startup.
|
- Reliability: improved OpenCode binary resolution and HOME-path handling across runtimes for steadier local startup.
|
||||||
|
|
||||||
|
|
||||||
## [1.6.4] - 2026-02-5
|
## [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.
|
- 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).
|
- 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).
|
- GitHub: PR description generation supports optional extra context for better summaries (thanks to @nelsonPires5).
|
||||||
|
|
||||||
|
|
||||||
## [1.6.3] - 2026-02-2
|
## [1.6.3] - 2026-02-2
|
||||||
|
|
||||||
- Web: improved server readiness check to use the `/global/health` endpoint for more reliable startup detection.
|
- 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).
|
- 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.
|
- Settings: dialog no longer persists open/closed state across app restarts.
|
||||||
|
|
||||||
|
|
||||||
## [1.6.2] - 2026-02-1
|
## [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).
|
- 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).
|
- 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).
|
- Projects: fixed directory creation outside workspace in the Add Project modal (thanks to @nelsonPires5).
|
||||||
|
|
||||||
|
|
||||||
## [1.6.1] - 2026-01-30
|
## [1.6.1] - 2026-01-30
|
||||||
|
|
||||||
- Chat: added Stop button to cancel generation mid-response.
|
- 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).
|
- 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).
|
- 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
|
## [1.6.0] - 2026-01-29
|
||||||
|
|
||||||
- Chat: added message stall detection with automatic soft resync for more reliable message delivery.
|
- 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.
|
- Web: session activity tracking now works consistently across browser tabs.
|
||||||
- Reliability: plans directory no longer errors when missing.
|
- Reliability: plans directory no longer errors when missing.
|
||||||
|
|
||||||
|
|
||||||
## [1.5.9] - 2026-01-28
|
## [1.5.9] - 2026-01-28
|
||||||
|
|
||||||
- Worktrees: migrated to Opencode SDK worktree implementation; sessions in worktrees are now completely isolated.
|
- 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.
|
- 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.
|
- Web: plan lists no longer error when the plans directory is missing.
|
||||||
|
|
||||||
|
|
||||||
## [1.5.8] - 2026-01-26
|
## [1.5.8] - 2026-01-26
|
||||||
|
|
||||||
- Plans: new Plan/Build mode switching support with dedicated Plan content view with per-session context.
|
- 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).
|
- 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.
|
- Reliability: file lists and message sends handle missing directories and transient errors more gracefully.
|
||||||
|
|
||||||
|
|
||||||
## [1.5.7] - 2026-01-24
|
## [1.5.7] - 2026-01-24
|
||||||
|
|
||||||
- GitHub: PR panel supports fork PR detection by branch name.
|
- 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.
|
- 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.
|
- Web: GitHub auth flow fixes.
|
||||||
|
|
||||||
|
|
||||||
## [1.5.6] - 2026-01-24
|
## [1.5.6] - 2026-01-24
|
||||||
|
|
||||||
- GitHub: connect your account in Settings with device-flow auth to enable GitHub tools.
|
- 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.
|
- 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).
|
- Mobile: fixed CommandAutocomplete dropdown scrolling (thanks to @nelsonPires5).
|
||||||
|
|
||||||
|
|
||||||
## [1.5.5] - 2026-01-23
|
## [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).
|
- 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: 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.
|
- Web: improved push subscription handling across multiple windows for more reliable delivery.
|
||||||
|
|
||||||
|
|
||||||
## [1.5.4] - 2026-01-22
|
## [1.5.4] - 2026-01-22
|
||||||
|
|
||||||
- Chat: new Apply Patch tool UI with diff preview for patch-based edits.
|
- 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.
|
- Web: added Background notifications for PWA.
|
||||||
- Reliability: connect to external OpenCode servers without auto-start and fixed subagent crashes (thanks to @TaylorBeeston).
|
- Reliability: connect to external OpenCode servers without auto-start and fixed subagent crashes (thanks to @TaylorBeeston).
|
||||||
|
|
||||||
|
|
||||||
## [1.5.3] - 2026-01-20
|
## [1.5.3] - 2026-01-20
|
||||||
|
|
||||||
- Files: edit files inline with syntax highlighting, draft protection, and save/discard flow.
|
- 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).
|
- 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).
|
- Performance: faster filesystem/search operations and general stability improvements (thanks to @TheRealAshik).
|
||||||
|
|
||||||
|
|
||||||
## [1.5.2] - 2026-01-17
|
## [1.5.2] - 2026-01-17
|
||||||
|
|
||||||
- Sessions: added branch picker dialog to start new worktree sessions from local branches (thanks to @nilskroe).
|
- 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.
|
- VSCode: tuned layout breakpoint and server readiness timeout for steadier startup.
|
||||||
- Reliability: improved OpenCode process cleanup to reduce orphaned servers.
|
- Reliability: improved OpenCode process cleanup to reduce orphaned servers.
|
||||||
|
|
||||||
|
|
||||||
## [1.5.1] - 2026-01-16
|
## [1.5.1] - 2026-01-16
|
||||||
|
|
||||||
- Desktop: fixed orphaned OpenCode processes not being cleaned up on restart or exit.
|
- Desktop: fixed orphaned OpenCode processes not being cleaned up on restart or exit.
|
||||||
- Opencode: fixed issue with reloading configuration was killing the app
|
- Opencode: fixed issue with reloading configuration was killing the app
|
||||||
|
|
||||||
|
|
||||||
## [1.5.0] - 2026-01-16
|
## [1.5.0] - 2026-01-16
|
||||||
|
|
||||||
- UI: added a new Files tab to browse workspace files directly from the interface.
|
- 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).
|
- Stability: fixed heartbeat race condition causing session stalls during long tasks (thanks to @tybradle).
|
||||||
- Desktop: fixed commands for worktree setup access to PATH.
|
- Desktop: fixed commands for worktree setup access to PATH.
|
||||||
|
|
||||||
|
|
||||||
## [1.4.9] - 2026-01-14
|
## [1.4.9] - 2026-01-14
|
||||||
|
|
||||||
- VSCode: added session editor panel to view sessions alongside files.
|
- 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).
|
- 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.
|
- Upload: increased attachment size limit to 50MB with automatic image compression to 2048px for large files.
|
||||||
|
|
||||||
|
|
||||||
## [1.4.8] - 2026-01-14
|
## [1.4.8] - 2026-01-14
|
||||||
|
|
||||||
- Git Identities: added token-based authentication support with ~/.git-credentials discovery and import.
|
- 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.
|
- 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).
|
- Stability: added graceful shutdown handling for the server process (thanks to @vio1ator).
|
||||||
|
|
||||||
|
|
||||||
## [1.4.7] - 2026-01-10
|
## [1.4.7] - 2026-01-10
|
||||||
|
|
||||||
- Skills: added ClawdHub integration as built-in market for skills.
|
- Skills: added ClawdHub integration as built-in market for skills.
|
||||||
- Web: fixed issues in terminal
|
- Web: fixed issues in terminal
|
||||||
|
|
||||||
|
|
||||||
## [1.4.6] - 2026-01-09
|
## [1.4.6] - 2026-01-09
|
||||||
|
|
||||||
- VSCode/Web: switch opencode cli management to SDK.
|
- 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.
|
- Shortcuts: switched agent cycling shortcut from Shift + TAB to TAB again.
|
||||||
- Chat: added question tool support with a rich UI for interaction.
|
- Chat: added question tool support with a rich UI for interaction.
|
||||||
|
|
||||||
|
|
||||||
## [1.4.5] - 2026-01-08
|
## [1.4.5] - 2026-01-08
|
||||||
|
|
||||||
- Chat: added support for model variants (thinking effort).
|
- 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.
|
- MCP: added ability to dynamically enabled/disabled configured MCP.
|
||||||
- Web: refactored project adding UI with autocomplete.
|
- Web: refactored project adding UI with autocomplete.
|
||||||
|
|
||||||
|
|
||||||
## [1.4.4] - 2026-01-08
|
## [1.4.4] - 2026-01-08
|
||||||
|
|
||||||
- Agent Manager / Multi Run: select agent per worktree session (thanks to @wienans).
|
- 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).
|
- Tunnel: added QR code and password URL for Cloudflare tunnel (thanks to @martindonadieu).
|
||||||
- Model selector: fixed dropdowns not responding to viewport size.
|
- Model selector: fixed dropdowns not responding to viewport size.
|
||||||
|
|
||||||
|
|
||||||
## [1.4.3] - 2026-01-04
|
## [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).
|
- 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).
|
- 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.
|
- VS Code extension: improved activation reliability and error handling.
|
||||||
|
|
||||||
|
|
||||||
## [1.4.2] - 2026-01-02
|
## [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).
|
- 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).
|
- 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.
|
- Migrated to OpenCode SDK v2 with improved API types and streaming.
|
||||||
|
|
||||||
|
|
||||||
## [1.4.1] - 2026-01-02
|
## [1.4.1] - 2026-01-02
|
||||||
|
|
||||||
- Added the ability to select the same model multiple times in multi-agent runs for response comparison.
|
- 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: 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.
|
- 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
|
## [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.
|
- 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.
|
- Chat: now shows clearer error messages when agent messages fail.
|
||||||
- Sidebar: improved readability for sticky headers with a dynamic background.
|
- Sidebar: improved readability for sticky headers with a dynamic background.
|
||||||
|
|
||||||
|
|
||||||
## [1.3.9] - 2025-12-30
|
## [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.
|
- 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).
|
- 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
|
## [1.3.8] - 2025-12-29
|
||||||
|
|
||||||
- Added Intel Mac (x86_64) support for the desktop application (thanks to @rothnic).
|
- 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.
|
- Fixed scroll position persistence for active conversation turns across session switches.
|
||||||
- Refactored Agents/Commands management with ability to configure project/user scopes.
|
- Refactored Agents/Commands management with ability to configure project/user scopes.
|
||||||
|
|
||||||
|
|
||||||
## [1.3.7] - 2025-12-28
|
## [1.3.7] - 2025-12-28
|
||||||
|
|
||||||
- Redesigned Settings as a full-screen view with tabbed navigation.
|
- 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.
|
- Improved session activity status handling and message step completion logic.
|
||||||
- Introduced enchanced VSCode extension settings with dynamic layout based on width.
|
- Introduced enchanced VSCode extension settings with dynamic layout based on width.
|
||||||
|
|
||||||
|
|
||||||
## [1.3.6] - 2025-12-27
|
## [1.3.6] - 2025-12-27
|
||||||
|
|
||||||
- Added the ability to manage (connect/disconnect) providers in settings.
|
- Added the ability to manage (connect/disconnect) providers in settings.
|
||||||
- Adjusted auto-summarization visuals in chat.
|
- Adjusted auto-summarization visuals in chat.
|
||||||
|
|
||||||
|
|
||||||
## [1.3.5] - 2025-12-26
|
## [1.3.5] - 2025-12-26
|
||||||
|
|
||||||
- Added Nushell support for operations with Opencode CLI.
|
- 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 Discord links in the about section.
|
||||||
- Added settings for choosing the default model/agent to start with in a new session.
|
- Added settings for choosing the default model/agent to start with in a new session.
|
||||||
|
|
||||||
|
|
||||||
## [1.3.4] - 2025-12-25
|
## [1.3.4] - 2025-12-25
|
||||||
|
|
||||||
- Diff view now loads reliably even with large files and slow networks.
|
- Diff view now loads reliably even with large files and slow networks.
|
||||||
- Fixed getting diffs for worktree files.
|
- Fixed getting diffs for worktree files.
|
||||||
- VS Code extension: improved type checking and editor integration.
|
- VS Code extension: improved type checking and editor integration.
|
||||||
|
|
||||||
|
|
||||||
## [1.3.3] - 2025-12-25
|
## [1.3.3] - 2025-12-25
|
||||||
|
|
||||||
- Updated OpenCode SDK to 1.0.185 across all app versions.
|
- 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 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.
|
- Chat UI: improved agent activity status behavior and reduced image thumbnail sizes for better readability.
|
||||||
|
|
||||||
|
|
||||||
## [1.3.2] - 2025-12-22
|
## [1.3.2] - 2025-12-22
|
||||||
|
|
||||||
- Fixed new bug session when switching directories
|
- Fixed new bug session when switching directories
|
||||||
- Updated Opencode SDK to the latest version
|
- Updated Opencode SDK to the latest version
|
||||||
|
|
||||||
|
|
||||||
## [1.3.1] - 2025-12-22
|
## [1.3.1] - 2025-12-22
|
||||||
|
|
||||||
- New chats no longer create a session until you send your first message.
|
- 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
|
- Fixed mobile and VSCode sessions handling
|
||||||
- Updated app identity with new logo and icons across all platforms.
|
- Updated app identity with new logo and icons across all platforms.
|
||||||
|
|
||||||
|
|
||||||
## [1.3.0] - 2025-12-21
|
## [1.3.0] - 2025-12-21
|
||||||
|
|
||||||
- Added revert functionality in chat for user messages.
|
- 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.
|
- Adjusted VSCode extension theme mapping and model selection view.
|
||||||
- Polished file autocomplete experience.
|
- Polished file autocomplete experience.
|
||||||
|
|
||||||
|
|
||||||
## [1.2.9] - 2025-12-20
|
## [1.2.9] - 2025-12-20
|
||||||
|
|
||||||
- Session auto‑cleanup feature with configurable retention for each app version including VSCode extension.
|
- Session auto‑cleanup feature with configurable retention for each app version including VSCode extension.
|
||||||
- Ability to update web package from mobile/PWA view in setting.
|
- Ability to update web package from mobile/PWA view in setting.
|
||||||
- A lot of different optimization for a long sessions.
|
- A lot of different optimization for a long sessions.
|
||||||
|
|
||||||
|
|
||||||
## [1.2.8] - 2025-12-19
|
## [1.2.8] - 2025-12-19
|
||||||
|
|
||||||
- Introduced update mechanism for web version that doesn't need any cli interaction.
|
- Introduced update mechanism for web version that doesn't need any cli interaction.
|
||||||
- Added installation script for web version with package managed detection.
|
- 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.
|
- 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
|
## [1.2.7] - 2025-12-19
|
||||||
|
|
||||||
- Comprehensive macOS native menu bar entries.
|
- 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.
|
- Improved theme consistency across dropdown menus, selects, and command palette.
|
||||||
- Introduced keyboard shortcuts help menu and quick actions menu.
|
- Introduced keyboard shortcuts help menu and quick actions menu.
|
||||||
|
|
||||||
|
|
||||||
## [1.2.6] - 2025-12-19
|
## [1.2.6] - 2025-12-19
|
||||||
|
|
||||||
- Added write/create tool preview in permission cards with syntax highlighting.
|
- Added write/create tool preview in permission cards with syntax highlighting.
|
||||||
- More descriptive assistant status messages with tool-specific and varied idle phrases.
|
- More descriptive assistant status messages with tool-specific and varied idle phrases.
|
||||||
- Polished Git view layout
|
- Polished Git view layout
|
||||||
|
|
||||||
|
|
||||||
## [1.2.5] - 2025-12-19
|
## [1.2.5] - 2025-12-19
|
||||||
|
|
||||||
- Polished chat expirience for longer session.
|
- 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.
|
- Fixed untracked files in new directories not showing individually.
|
||||||
- Smoother session rename experience.
|
- Smoother session rename experience.
|
||||||
|
|
||||||
|
|
||||||
## [1.2.4] - 2025-12-18
|
## [1.2.4] - 2025-12-18
|
||||||
|
|
||||||
- MacOS app menu entries for Check for update and for creating bug/request in Help section.
|
- 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.
|
- For Mobile added settings, improved terminal scrolling, fixed app layout positioning.
|
||||||
|
|
||||||
|
|
||||||
## [1.2.3] - 2025-12-17
|
## [1.2.3] - 2025-12-17
|
||||||
|
|
||||||
- Added image preview support in Diff tab (shows original/modified images instead of base64 code).
|
- 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.
|
- Optimized git polling and background diff+syntax pre-warm for instant Diff tab open.
|
||||||
- Optomized reloading unaffected diffs.
|
- Optomized reloading unaffected diffs.
|
||||||
|
|
||||||
|
|
||||||
## [1.2.2] - 2025-12-17
|
## [1.2.2] - 2025-12-17
|
||||||
|
|
||||||
- Agent Task tool now renders progressively with live duration and completed sub-tools summary.
|
- Agent Task tool now renders progressively with live duration and completed sub-tools summary.
|
||||||
- Unified markdown rendering between assistant messages and tool outputs.
|
- Unified markdown rendering between assistant messages and tool outputs.
|
||||||
- Reduced markdown header sizes for better visual balance.
|
- Reduced markdown header sizes for better visual balance.
|
||||||
|
|
||||||
|
|
||||||
## [1.2.1] - 2025-12-16
|
## [1.2.1] - 2025-12-16
|
||||||
|
|
||||||
- Todo task tracking: collapsible status row showing AI's current task and progress.
|
- 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.
|
- Switched "Detailed" tool output mode to only open the 'task', 'edit', 'multiedit', 'write', 'bash' tools for better performance.
|
||||||
|
|
||||||
|
|
||||||
## [1.2.0] - 2025-12-15
|
## [1.2.0] - 2025-12-15
|
||||||
|
|
||||||
- Favorite & recent models for quick access in model selection.
|
- Favorite & recent models for quick access in model selection.
|
||||||
- Tool call expansion settings: collapsed, activity, or detailed modes.
|
- Tool call expansion settings: collapsed, activity, or detailed modes.
|
||||||
- Font size & spacing controls (50-200% scaling) in Appearance Settings.
|
- Font size & spacing controls (50-200% scaling) in Appearance Settings.
|
||||||
- Settings page access within VSCode extension.
|
- 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
|
## [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.
|
- Improved mobile experience: simplified header, better diff file selector.
|
||||||
- Redesigned password-protected session unlock screen.
|
- Redesigned password-protected session unlock screen.
|
||||||
|
|
||||||
|
|
||||||
## [1.1.5] - 2025-12-15
|
## [1.1.5] - 2025-12-15
|
||||||
|
|
||||||
- Enhanced file attachment features performance.
|
- Enhanced file attachment features performance.
|
||||||
- Added fuzzy search feature for file mentioning with @ in chat.
|
- Added fuzzy search feature for file mentioning with @ in chat.
|
||||||
- Optimized input area layout.
|
- Optimized input area layout.
|
||||||
|
|
||||||
|
|
||||||
## [1.1.4] - 2025-12-15
|
## [1.1.4] - 2025-12-15
|
||||||
|
|
||||||
- Flexoki themes for Shiki syntax highlighting for consistency with the app color schema.
|
- Flexoki themes for Shiki syntax highlighting for consistency with the app color schema.
|
||||||
- Enchanced VSCode extension theming with editor themes.
|
- Enchanced VSCode extension theming with editor themes.
|
||||||
- Fixed mobile view model/agent selection.
|
- Fixed mobile view model/agent selection.
|
||||||
|
|
||||||
|
|
||||||
## [1.1.3] - 2025-12-14
|
## [1.1.3] - 2025-12-14
|
||||||
|
|
||||||
- Replaced Monaco diff editor with Pierre/diffs for better performance.
|
- 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).
|
- Added line wrap toggle in diff view with dynamic layout switching (auto-inline when narrow).
|
||||||
|
|
||||||
|
|
||||||
## [1.1.2] - 2025-12-13
|
## [1.1.2] - 2025-12-13
|
||||||
|
|
||||||
- Moved VS Code extension to activity bar (left sidebar).
|
- 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.
|
- Removed redundant VS Code commands.
|
||||||
- Enhanced UserTextPart styling.
|
- Enhanced UserTextPart styling.
|
||||||
|
|
||||||
|
|
||||||
## [1.1.1] - 2025-12-13
|
## [1.1.1] - 2025-12-13
|
||||||
|
|
||||||
- Adjusted model/agent selection alignment.
|
- Adjusted model/agent selection alignment.
|
||||||
- Fixed user message rendering issues.
|
- Fixed user message rendering issues.
|
||||||
|
|
||||||
|
|
||||||
## [1.1.0] - 2025-12-13
|
## [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.
|
- 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.
|
- Improved scroll performance with force flag and RAF placeholder.
|
||||||
- Added git polling backoff optimization.
|
- Added git polling backoff optimization.
|
||||||
|
|
||||||
|
|
||||||
## [1.0.9] - 2025-12-08
|
## [1.0.9] - 2025-12-08
|
||||||
|
|
||||||
- Added directory picker on first launch to reduce macOS permission prompts.
|
- 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.
|
- Improved update dialog UI with inline version display.
|
||||||
- Added macOS folder access usage descriptions.
|
- Added macOS folder access usage descriptions.
|
||||||
|
|
||||||
|
|
||||||
## [1.0.8] - 2025-12-08
|
## [1.0.8] - 2025-12-08
|
||||||
|
|
||||||
- Added fallback detection for OpenCode CLI in ~/.opencode/bin.
|
- Added fallback detection for OpenCode CLI in ~/.opencode/bin.
|
||||||
- Added window focus after app restart/update.
|
- Added window focus after app restart/update.
|
||||||
- Adapted traffic lights position and corner radius for older macOS versions.
|
- Adapted traffic lights position and corner radius for older macOS versions.
|
||||||
|
|
||||||
|
|
||||||
## [1.0.7] - 2025-12-08
|
## [1.0.7] - 2025-12-08
|
||||||
|
|
||||||
- Optimized Opencode binary detection.
|
- Optimized Opencode binary detection.
|
||||||
- Adjusted app update experience.
|
- Adjusted app update experience.
|
||||||
|
|
||||||
|
|
||||||
## [1.0.6] - 2025-12-08
|
## [1.0.6] - 2025-12-08
|
||||||
|
|
||||||
- Enhance shell environment detection.
|
- Enhance shell environment detection.
|
||||||
|
|
||||||
|
|
||||||
## [1.0.5] - 2025-12-07
|
## [1.0.5] - 2025-12-07
|
||||||
|
|
||||||
- Fixed "Load older messages" incorrectly scrolling to bottom.
|
- Fixed "Load older messages" incorrectly scrolling to bottom.
|
||||||
- Fixed page refresh getting stuck on splash screen.
|
- Fixed page refresh getting stuck on splash screen.
|
||||||
- Disabled devtools and page refresh in production builds.
|
- Disabled devtools and page refresh in production builds.
|
||||||
|
|
||||||
|
|
||||||
## [1.0.4] - 2025-12-07
|
## [1.0.4] - 2025-12-07
|
||||||
|
|
||||||
- Optimized desktop app start time
|
- Optimized desktop app start time
|
||||||
|
|
||||||
|
|
||||||
## [1.0.3] - 2025-12-07
|
## [1.0.3] - 2025-12-07
|
||||||
|
|
||||||
- Updated onboarding UI.
|
- Updated onboarding UI.
|
||||||
- Updated sidebar styles.
|
- Updated sidebar styles.
|
||||||
|
|
||||||
|
|
||||||
## [1.0.2] - 2025-12-07
|
## [1.0.2] - 2025-12-07
|
||||||
|
|
||||||
- Updated MacOS window design to the latest one.
|
- Updated MacOS window design to the latest one.
|
||||||
|
|
||||||
|
|
||||||
## [1.0.1] - 2025-12-07
|
## [1.0.1] - 2025-12-07
|
||||||
|
|
||||||
- Initial public release of OpenChamber web and desktop packages in a unified monorepo.
|
- Initial public release of OpenChamber web and desktop packages in a unified monorepo.
|
||||||
|
|||||||
@@ -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`).
|
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
|
### Tunnel behavior notes
|
||||||
|
|
||||||
- OpenChamber supports one active tunnel per running instance (port).
|
- OpenChamber supports one active tunnel per running instance (port).
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "openchamber-monorepo",
|
"name": "openchamber-monorepo",
|
||||||
"version": "1.9.1",
|
"version": "1.9.9",
|
||||||
"description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes",
|
"description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -26,14 +26,17 @@
|
|||||||
"build:web": "bun run --cwd packages/web build",
|
"build:web": "bun run --cwd packages/web build",
|
||||||
"build:ui": "bun run --cwd packages/ui build",
|
"build:ui": "bun run --cwd packages/ui build",
|
||||||
"build:desktop": "bun run --cwd packages/desktop 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": "bun run --filter '*' type-check",
|
||||||
"type-check:web": "bun run --cwd packages/web type-check",
|
"type-check:web": "bun run --cwd packages/web type-check",
|
||||||
"type-check:ui": "bun run --cwd packages/ui type-check",
|
"type-check:ui": "bun run --cwd packages/ui type-check",
|
||||||
"type-check:desktop": "bun run --cwd packages/desktop 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": "bun run --filter '*' lint",
|
||||||
"lint:web": "bun run --cwd packages/web lint",
|
"lint:web": "bun run --cwd packages/web lint",
|
||||||
"lint:ui": "bun run --cwd packages/ui lint",
|
"lint:ui": "bun run --cwd packages/ui lint",
|
||||||
"lint:desktop": "bun run --cwd packages/desktop lint",
|
"lint:desktop": "bun run --cwd packages/desktop lint",
|
||||||
|
"lint:electron": "bun run --cwd packages/electron lint",
|
||||||
"clean": "bun run --filter '*' clean",
|
"clean": "bun run --filter '*' clean",
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
"dev:web": "bun run --cwd packages/web build:watch",
|
"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:stop-cli": "node ./packages/desktop/scripts/opencode-cli.mjs stop",
|
||||||
"desktop:dev": "node ./packages/desktop/scripts/desktop-dev.mjs",
|
"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",
|
"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: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",
|
"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",
|
"vscode:dev": "node ./scripts/dev-vscode.mjs",
|
||||||
@@ -76,11 +81,11 @@
|
|||||||
"@codemirror/lang-sql": "^6.10.0",
|
"@codemirror/lang-sql": "^6.10.0",
|
||||||
"@codemirror/lang-xml": "^6.1.0",
|
"@codemirror/lang-xml": "^6.1.0",
|
||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
"@codemirror/language": "^6.12.1",
|
"@codemirror/language": "6.12.2",
|
||||||
"@codemirror/lint": "^6.9.2",
|
"@codemirror/lint": "^6.9.2",
|
||||||
"@codemirror/search": "^6.6.0",
|
"@codemirror/search": "^6.6.0",
|
||||||
"@codemirror/state": "^6.5.4",
|
"@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-mono": "^5.2.7",
|
||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@heroui/scroll-shadow": "^2.3.18",
|
"@heroui/scroll-shadow": "^2.3.18",
|
||||||
@@ -89,7 +94,8 @@
|
|||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@octokit/rest": "^22.0.1",
|
"@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-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
|||||||
@@ -61,19 +61,6 @@
|
|||||||
font-family: system-ui, -apple-system, sans-serif;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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="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="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"/>
|
<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 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"/>
|
<path d="M-8 -4 L8 -4 L8 12 L-8 12 Z" fill="var(--splash-logo-fill)" fill-opacity="0.4"/>
|
||||||
</g>
|
</g>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@openchamber/desktop",
|
"name": "@openchamber/desktop",
|
||||||
"version": "1.9.1",
|
"version": "1.9.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"desktopPrerequisites": [
|
"desktopPrerequisites": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "openchamber-desktop"
|
name = "openchamber-desktop"
|
||||||
version = "1.9.1"
|
version = "1.9.9"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
@@ -16,20 +16,27 @@ devtools = ["tauri/devtools"]
|
|||||||
anyhow = "1.0.86"
|
anyhow = "1.0.86"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
log = "0.4.28"
|
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 = { version = "1.0.210", features = ["derive"] }
|
||||||
serde_json = "1.0.143"
|
serde_json = "1.0.143"
|
||||||
tauri = { version = "2.9.4", features = ["macos-private-api"] }
|
tauri = { version = "2.10.3", features = ["macos-private-api"] }
|
||||||
tauri-plugin-dialog = "2.4.2"
|
tauri-plugin-dialog = "2.6.0"
|
||||||
tauri-plugin-log = "2.7.1"
|
tauri-plugin-log = "2.8.0"
|
||||||
tauri-plugin-shell = "2.3.3"
|
tauri-plugin-shell = "2.3.5"
|
||||||
tauri-plugin-notification = "2.3.3"
|
tauri-plugin-notification = "2.3.3"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2.10.0"
|
||||||
tokio = { version = "1.38", features = ["rt-multi-thread", "time"] }
|
tokio = { version = "1.38", features = ["rt-multi-thread", "time", "macros", "sync"] }
|
||||||
url = "2.5"
|
url = "2.5"
|
||||||
|
|
||||||
[build-dependencies]
|
[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]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
window-vibrancy = "0.7.1"
|
objc2 = "0.6"
|
||||||
|
objc2-web-kit = "0.3"
|
||||||
|
rfd = "0.15"
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ const DEFAULT_READY_TIMEOUT_SEC: u64 = 30;
|
|||||||
const DEFAULT_RECONNECT_MAX_ATTEMPTS: u32 = 5;
|
const DEFAULT_RECONNECT_MAX_ATTEMPTS: u32 = 5;
|
||||||
const MAX_LOG_LINES_PER_INSTANCE: usize = 1200;
|
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)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct DesktopSshInstancesConfig {
|
pub struct DesktopSshInstancesConfig {
|
||||||
@@ -1027,6 +1033,7 @@ fn wait_for_master_ready(
|
|||||||
master: &mut Child,
|
master: &mut Child,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let deadline = std::time::Instant::now() + Duration::from_secs(timeout_sec as u64);
|
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 {
|
while std::time::Instant::now() < deadline {
|
||||||
let args = vec![
|
let args = vec![
|
||||||
"-o".to_string(),
|
"-o".to_string(),
|
||||||
@@ -1056,7 +1063,8 @@ fn wait_for_master_ready(
|
|||||||
return Err(anyhow!(stderr.trim().to_string()));
|
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"))
|
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<()> {
|
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 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 {
|
while std::time::Instant::now() < deadline {
|
||||||
if let Ok(response) = client.get(&target).send() {
|
if let Ok(mut stream) =
|
||||||
if response.status().is_success() || response.status().as_u16() == 401 {
|
TcpStream::connect_timeout(&addr, Duration::from_millis(1000))
|
||||||
return Ok(());
|
{
|
||||||
|
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!(
|
Err(anyhow!(
|
||||||
"Timed out waiting for forwarded OpenChamber health"
|
"Timed out waiting for forwarded OpenChamber health"
|
||||||
@@ -2353,8 +2376,14 @@ impl DesktopSshManagerInner {
|
|||||||
let inner = Arc::clone(self);
|
let inner = Arc::clone(self);
|
||||||
let id_for_task = id.clone();
|
let id_for_task = id.clone();
|
||||||
let handle = tauri::async_runtime::spawn(async move {
|
let handle = tauri::async_runtime::spawn(async move {
|
||||||
|
let mut healthy_ticks: u32 = 0;
|
||||||
loop {
|
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 dropped_reason: Option<String> = None;
|
||||||
let mut detached_notice: Option<String> = None;
|
let mut detached_notice: Option<String> = None;
|
||||||
@@ -2424,18 +2453,21 @@ impl DesktopSshManagerInner {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if session.master_detached {
|
} else if session.master_detached {
|
||||||
if !is_control_master_alive(&session.parsed, &session.control_path) {
|
// Fast path: check local tunnel first (cheap TCP probe)
|
||||||
if is_local_tunnel_reachable(session.local_port) {
|
// before spawning an SSH subprocess for control master check.
|
||||||
if detached_notice.is_none() {
|
if is_local_tunnel_reachable(session.local_port) {
|
||||||
detached_notice = Some(
|
// Tunnel is alive — skip the expensive SSH check entirely.
|
||||||
"SSH ControlMaster check failed but local tunnel is still reachable"
|
} else if !is_control_master_alive(
|
||||||
.to_string(),
|
&session.parsed,
|
||||||
);
|
&session.control_path,
|
||||||
}
|
) {
|
||||||
} else {
|
dropped_reason =
|
||||||
dropped_reason =
|
Some("SSH ControlMaster is not reachable".to_string());
|
||||||
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() {
|
} else if let Some(status) = session.master.try_wait().ok().flatten() {
|
||||||
if status.success()
|
if status.success()
|
||||||
@@ -2471,6 +2503,7 @@ impl DesktopSshManagerInner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if dropped_reason.is_none() {
|
if dropped_reason.is_none() {
|
||||||
|
healthy_ticks = healthy_ticks.saturating_add(1);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
||||||
"productName": "OpenChamber",
|
"productName": "OpenChamber",
|
||||||
"version": "1.9.1",
|
"version": "1.9.9",
|
||||||
"identifier": "ai.opencode.openchamber",
|
"identifier": "ai.opencode.openchamber",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "node ./scripts/dev-web-server.mjs",
|
"beforeDevCommand": "node ./scripts/dev-web-server.mjs",
|
||||||
@@ -15,7 +15,6 @@
|
|||||||
"label": "main",
|
"label": "main",
|
||||||
"create": false,
|
"create": false,
|
||||||
"title": "OpenChamber",
|
"title": "OpenChamber",
|
||||||
"transparent": true,
|
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
"height": 800,
|
"height": 800,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
@@ -28,8 +27,7 @@
|
|||||||
"y": 26
|
"y": 26
|
||||||
},
|
},
|
||||||
"dragDropEnabled": false,
|
"dragDropEnabled": false,
|
||||||
"visible": false,
|
"visible": false
|
||||||
"backgroundThrottling": "disabled"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"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/)
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
{
|
{
|
||||||
"label": "Help",
|
"label": "Help",
|
||||||
"items": [
|
"items": [
|
||||||
|
{ "label": "Reverse Proxy", "link": "/reverse-proxy/" },
|
||||||
{ "label": "Troubleshooting", "link": "/troubleshooting/" }
|
{ "label": "Troubleshooting", "link": "/troubleshooting/" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
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 |
|
After Width: | Height: | Size: 65 KiB |
|
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');
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@openchamber/ui",
|
"name": "@openchamber/ui",
|
||||||
"version": "1.9.1",
|
"version": "1.9.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/main.tsx",
|
"main": "src/main.tsx",
|
||||||
@@ -25,13 +25,13 @@
|
|||||||
"@codemirror/lang-sql": "^6.10.0",
|
"@codemirror/lang-sql": "^6.10.0",
|
||||||
"@codemirror/lang-xml": "^6.1.0",
|
"@codemirror/lang-xml": "^6.1.0",
|
||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
"@codemirror/language": "^6.12.1",
|
"@codemirror/language": "6.12.2",
|
||||||
"@codemirror/language-data": "^6.5.2",
|
"@codemirror/language-data": "^6.5.2",
|
||||||
"@codemirror/legacy-modes": "^6.5.2",
|
"@codemirror/legacy-modes": "^6.5.2",
|
||||||
"@codemirror/lint": "^6.9.2",
|
"@codemirror/lint": "^6.9.2",
|
||||||
"@codemirror/search": "^6.6.0",
|
"@codemirror/search": "^6.6.0",
|
||||||
"@codemirror/state": "^6.5.4",
|
"@codemirror/state": "^6.5.4",
|
||||||
"@codemirror/view": "^6.39.13",
|
"@codemirror/view": "6.39.13",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -39,20 +39,11 @@
|
|||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@opencode-ai/sdk": "^1.3.0",
|
"@opencode-ai/sdk": "^1.4.25",
|
||||||
"@pierre/diffs": "1.1.0-beta.13",
|
"@pierre/diffs": "1.1.0-beta.13",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@base-ui/react": "^1.4.0",
|
||||||
"@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",
|
|
||||||
"@remixicon/react": "^4.7.0",
|
"@remixicon/react": "^4.7.0",
|
||||||
"@streamdown/code": "^1.0.2",
|
"@simplewebauthn/browser": "13.3.0",
|
||||||
"@tanstack/react-virtual": "^3.13.18",
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"beautiful-mermaid": "^1.1.3",
|
"beautiful-mermaid": "^1.1.3",
|
||||||
@@ -60,12 +51,18 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"codemirror-lang-elixir": "^4.0.0",
|
"codemirror-lang-elixir": "^4.0.0",
|
||||||
|
"dompurify": "^3.2.7",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"ghostty-web": "^0.4.0",
|
"ghostty-web": "^0.4.0",
|
||||||
"heic2any": "^0.0.4",
|
"heic2any": "^0.0.4",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"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",
|
"motion": "^12.23.24",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
@@ -73,9 +70,9 @@
|
|||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-syntax-highlighter": "^15.6.6",
|
"react-syntax-highlighter": "^15.6.6",
|
||||||
|
"remend": "^1.2.1",
|
||||||
"simple-git": "^3.28.0",
|
"simple-git": "^3.28.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"streamdown": "^2.2.0",
|
|
||||||
"strip-json-comments": "^5.0.3",
|
"strip-json-comments": "^5.0.3",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"yaml": "^2.8.1",
|
"yaml": "^2.8.1",
|
||||||
@@ -85,7 +82,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.33.0",
|
"@eslint/js": "^9.33.0",
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"@tauri-apps/api": "^2.9.0",
|
"@tauri-apps/api": "^2.10.1",
|
||||||
"@types/node": "^24.3.1",
|
"@types/node": "^24.3.1",
|
||||||
"@types/prismjs": "^1.26.6",
|
"@types/prismjs": "^1.26.6",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
|
|||||||
@@ -5,31 +5,45 @@ import { AgentManagerView } from '@/components/views/agent-manager';
|
|||||||
import { ChatView } from '@/components/views';
|
import { ChatView } from '@/components/views';
|
||||||
import { FireworksProvider } from '@/contexts/FireworksContext';
|
import { FireworksProvider } from '@/contexts/FireworksContext';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel';
|
import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel';
|
||||||
|
import { setStreamPerfEnabled } from '@/stores/utils/streamDebug';
|
||||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||||
import { useEventStream } from '@/hooks/useEventStream';
|
// useEventStream removed — replaced by SyncProvider + SyncBridge
|
||||||
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
||||||
import { useMenuActions } from '@/hooks/useMenuActions';
|
import { useMenuActions } from '@/hooks/useMenuActions';
|
||||||
import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
|
import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
|
||||||
import { useServerSessionStatus } from '@/hooks/useServerSessionStatus';
|
|
||||||
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
|
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
|
||||||
import { useQueuedMessageAutoSend } from '@/hooks/useQueuedMessageAutoSend';
|
import { useQueuedMessageAutoSend } from '@/hooks/useQueuedMessageAutoSend';
|
||||||
import { useRouter } from '@/hooks/useRouter';
|
import { useRouter } from '@/hooks/useRouter';
|
||||||
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||||
import { usePwaManifestSync } from '@/hooks/usePwaManifestSync';
|
import { usePwaManifestSync } from '@/hooks/usePwaManifestSync';
|
||||||
import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt';
|
import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt';
|
||||||
|
import { useWindowControlsOverlayLayout } from '@/hooks/useWindowControlsOverlayLayout';
|
||||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||||
import { useGitHubPrBackgroundTracking } from '@/hooks/useGitHubPrBackgroundTracking';
|
|
||||||
import { GitPollingProvider } from '@/hooks/useGitPolling';
|
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { hasModifier } from '@/lib/utils';
|
import { hasModifier } from '@/lib/utils';
|
||||||
import { isDesktopLocalOriginActive, isDesktopShell } from '@/lib/desktop';
|
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell, restartDesktopApp } from '@/lib/desktop';
|
||||||
import { OnboardingScreen } from '@/components/onboarding/OnboardingScreen';
|
import {
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
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 { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
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 { useFontPreferences } from '@/hooks/useFontPreferences';
|
||||||
import { CODE_FONT_OPTION_MAP, DEFAULT_MONO_FONT, DEFAULT_UI_FONT, UI_FONT_OPTION_MAP } from '@/lib/fontOptions';
|
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 { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
|
||||||
import { AboutDialog } from '@/components/ui/AboutDialog';
|
import { AboutDialog } from '@/components/ui/AboutDialog';
|
||||||
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
|
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
|
||||||
@@ -37,15 +51,23 @@ import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
|||||||
import { VoiceProvider } from '@/components/voice';
|
import { VoiceProvider } from '@/components/voice';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||||
|
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
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 =
|
// Lazy-loaded heavy views — loaded on demand to reduce initial bundle size.
|
||||||
/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 OnboardingScreen = lazyWithChunkRecovery(() =>
|
||||||
const CLI_ONBOARDING_HEALTH_POLL_MS = 1500;
|
import('@/components/onboarding/OnboardingScreen').then((m) => ({ default: m.OnboardingScreen })),
|
||||||
|
);
|
||||||
|
|
||||||
const AboutDialogWrapper: React.FC = () => {
|
const AboutDialogWrapper: React.FC = () => {
|
||||||
const { isAboutDialogOpen, setAboutDialogOpen } = useUIStore();
|
const isAboutDialogOpen = useUIStore((s) => s.isAboutDialogOpen);
|
||||||
|
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
|
||||||
return (
|
return (
|
||||||
<AboutDialog
|
<AboutDialog
|
||||||
open={isAboutDialogOpen}
|
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 = {
|
type AppProps = {
|
||||||
apis: RuntimeAPIs;
|
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) {
|
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 providersCount = useConfigStore((state) => state.providers.length);
|
||||||
const agentsCount = useConfigStore((state) => state.agents.length);
|
const agentsCount = useConfigStore((state) => state.agents.length);
|
||||||
const loadProviders = useConfigStore((state) => state.loadProviders);
|
const loadProviders = useConfigStore((state) => state.loadProviders);
|
||||||
const loadAgents = useConfigStore((state) => state.loadAgents);
|
const loadAgents = useConfigStore((state) => state.loadAgents);
|
||||||
const { error, clearError, loadSessions } = useSessionStore();
|
const error = useSessionUIStore((s) => s.error);
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const clearError = useSessionUIStore((s) => s.clearError);
|
||||||
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
|
||||||
const sessions = useSessionStore((state) => state.sessions);
|
|
||||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||||
const setDirectory = useDirectoryStore((state) => state.setDirectory);
|
const setDirectory = useDirectoryStore((state) => state.setDirectory);
|
||||||
const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory);
|
const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory);
|
||||||
@@ -111,12 +218,33 @@ function App({ apis }: AppProps) {
|
|||||||
const { uiFont, monoFont } = useFontPreferences();
|
const { uiFont, monoFont } = useFontPreferences();
|
||||||
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
|
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
|
||||||
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode);
|
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode);
|
||||||
const [showCliOnboarding, setShowCliOnboarding] = React.useState(false);
|
|
||||||
const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(true);
|
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 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 appReadyDispatchedRef = React.useRef(false);
|
||||||
|
const initializationInFlightRef = React.useRef(false);
|
||||||
const embeddedSessionChat = React.useMemo<EmbeddedSessionChatConfig | null>(() => readEmbeddedSessionChatConfig(), []);
|
const embeddedSessionChat = React.useMemo<EmbeddedSessionChatConfig | null>(() => readEmbeddedSessionChatConfig(), []);
|
||||||
const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible;
|
const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible;
|
||||||
|
const isMcpOAuthCallback = React.useMemo(() => isMcpOAuthCallbackPath(), []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setStreamPerfEnabled(showMemoryDebug);
|
||||||
|
return () => {
|
||||||
|
setStreamPerfEnabled(false);
|
||||||
|
};
|
||||||
|
}, [showMemoryDebug]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setIsVSCodeRuntime(apis.runtime.isVSCode);
|
setIsVSCodeRuntime(apis.runtime.isVSCode);
|
||||||
@@ -135,8 +263,6 @@ function App({ apis }: AppProps) {
|
|||||||
void refreshGitHubAuthStatus(apis.github, { force: true });
|
void refreshGitHubAuthStatus(apis.github, { force: true });
|
||||||
}, [apis.github, embeddedSessionChat, refreshGitHubAuthStatus]);
|
}, [apis.github, embeddedSessionChat, refreshGitHubAuthStatus]);
|
||||||
|
|
||||||
useGitHubPrBackgroundTracking(embeddedBackgroundWorkEnabled ? apis.github : undefined, apis.git);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (typeof document === 'undefined') {
|
if (typeof document === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -144,6 +270,8 @@ function App({ apis }: AppProps) {
|
|||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const uiStack = UI_FONT_OPTION_MAP[uiFont]?.stack ?? UI_FONT_OPTION_MAP[DEFAULT_UI_FONT].stack;
|
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;
|
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-sans', uiStack);
|
||||||
root.style.setProperty('--font-heading', uiStack);
|
root.style.setProperty('--font-heading', uiStack);
|
||||||
@@ -157,25 +285,55 @@ function App({ apis }: AppProps) {
|
|||||||
}
|
}
|
||||||
}, [uiFont, monoFont]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
if (isInitialized) {
|
if (!canDismissInitialLoading({
|
||||||
const hideInitialLoading = () => {
|
isDesktopShell: isDesktopRuntime,
|
||||||
const loadingElement = document.getElementById('initial-loading');
|
isInitialized,
|
||||||
if (loadingElement) {
|
bootOutcomeKnown,
|
||||||
loadingElement.classList.add('fade-out');
|
bootViewIsMain,
|
||||||
|
})) {
|
||||||
setTimeout(() => {
|
return;
|
||||||
loadingElement.remove();
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const timer = setTimeout(hideInitialLoading, 150);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
}
|
||||||
}, [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(() => {
|
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 fallbackTimer = setTimeout(() => {
|
||||||
const loadingElement = document.getElementById('initial-loading');
|
const loadingElement = document.getElementById('initial-loading');
|
||||||
if (loadingElement && !isInitialized) {
|
if (loadingElement && !isInitialized) {
|
||||||
@@ -187,7 +345,29 @@ function App({ apis }: AppProps) {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
return () => clearTimeout(fallbackTimer);
|
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(() => {
|
React.useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
@@ -196,76 +376,138 @@ function App({ apis }: AppProps) {
|
|||||||
if (isVSCodeRuntime) {
|
if (isVSCodeRuntime) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await initializeApp();
|
if (initializationInFlightRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initializationInFlightRef.current = true;
|
||||||
|
try {
|
||||||
|
await initializeApp();
|
||||||
|
} finally {
|
||||||
|
initializationInFlightRef.current = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
init();
|
init();
|
||||||
}, [initializeApp, isVSCodeRuntime]);
|
}, [initializeApp, isVSCodeRuntime]);
|
||||||
|
|
||||||
const startupRecoveryInProgressRef = React.useRef(false);
|
|
||||||
const startupRecoveryLastAttemptRef = React.useRef(0);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isVSCodeRuntime) {
|
if (isVSCodeRuntime || isInitialized) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isConnected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (providersCount > 0 && agentsCount > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (startupRecoveryInProgressRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
let active = true;
|
||||||
if (now - startupRecoveryLastAttemptRef.current < 750) {
|
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
return;
|
let retryCount = 0;
|
||||||
}
|
const MAX_RETRIES = 10;
|
||||||
|
const BASE_DELAY_MS = 1000;
|
||||||
|
|
||||||
startupRecoveryLastAttemptRef.current = now;
|
const retryInitialization = async () => {
|
||||||
startupRecoveryInProgressRef.current = true;
|
if (!active) return;
|
||||||
|
if (retryCount >= MAX_RETRIES) {
|
||||||
const repair = async () => {
|
setInitRetryExhausted(true);
|
||||||
try {
|
return;
|
||||||
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 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();
|
retryTimer = setTimeout(retryInitialization, BASE_DELAY_MS);
|
||||||
}, [agentsCount, isConnected, isVSCodeRuntime, loadAgents, loadProviders, providersCount]);
|
|
||||||
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (isSwitchingDirectory) {
|
if (isSwitchingDirectory) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncDirectoryAndSessions = async () => {
|
// VS Code runtime loads sessions via VSCodeLayout bootstrap to avoid startup races.
|
||||||
// VS Code runtime loads sessions via VSCodeLayout bootstrap to avoid startup races.
|
if (isVSCodeRuntime) {
|
||||||
if (isVSCodeRuntime) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
opencodeClient.setDirectory(currentDirectory);
|
opencodeClient.setDirectory(currentDirectory);
|
||||||
|
|
||||||
await loadSessions();
|
// Session loading is handled by the sync system's bootstrap — no manual loadSessions needed.
|
||||||
};
|
}, [currentDirectory, isSwitchingDirectory, isConnected, isVSCodeRuntime]);
|
||||||
|
|
||||||
syncDirectoryAndSessions();
|
|
||||||
}, [currentDirectory, isSwitchingDirectory, loadSessions, isConnected, isVSCodeRuntime]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!embeddedSessionChat || typeof window === 'undefined') {
|
if (!embeddedSessionChat || typeof window === 'undefined') {
|
||||||
@@ -317,22 +559,6 @@ function App({ apis }: AppProps) {
|
|||||||
setDirectory(embeddedSessionChat.directory, { showOverlay: false });
|
setDirectory(embeddedSessionChat.directory, { showOverlay: false });
|
||||||
}, [currentDirectory, embeddedSessionChat, isVSCodeRuntime, setDirectory]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
if (!embeddedSessionChat || typeof window === 'undefined') {
|
if (!embeddedSessionChat || typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -356,6 +582,40 @@ function App({ apis }: AppProps) {
|
|||||||
};
|
};
|
||||||
}, [embeddedSessionChat]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
if (!isInitialized || isSwitchingDirectory) return;
|
if (!isInitialized || isSwitchingDirectory) return;
|
||||||
@@ -365,22 +625,17 @@ function App({ apis }: AppProps) {
|
|||||||
window.dispatchEvent(new Event('openchamber:app-ready'));
|
window.dispatchEvent(new Event('openchamber:app-ready'));
|
||||||
}, [isInitialized, isSwitchingDirectory]);
|
}, [isInitialized, isSwitchingDirectory]);
|
||||||
|
|
||||||
useEventStream({ enabled: embeddedBackgroundWorkEnabled });
|
// useEventStream replaced by SyncProvider + SyncBridge
|
||||||
|
|
||||||
// Server-authoritative session status polling
|
// Session attention now handled by notification-store via SSE events (session.idle/session.error)
|
||||||
// Replaces SSE-dependent status updates with reliable HTTP polling
|
|
||||||
useServerSessionStatus({ enabled: embeddedBackgroundWorkEnabled });
|
|
||||||
|
|
||||||
usePushVisibilityBeacon({ enabled: embeddedBackgroundWorkEnabled });
|
usePushVisibilityBeacon({ enabled: embeddedBackgroundWorkEnabled });
|
||||||
usePwaManifestSync();
|
|
||||||
usePwaInstallPrompt();
|
usePwaInstallPrompt();
|
||||||
|
|
||||||
useWindowTitle();
|
useWindowTitle();
|
||||||
|
|
||||||
useRouter();
|
useRouter();
|
||||||
|
|
||||||
useKeyboardShortcuts();
|
|
||||||
|
|
||||||
const handleToggleMemoryDebug = React.useCallback(() => {
|
const handleToggleMemoryDebug = React.useCallback(() => {
|
||||||
setShowMemoryDebug(prev => !prev);
|
setShowMemoryDebug(prev => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -388,8 +643,6 @@ function App({ apis }: AppProps) {
|
|||||||
useMenuActions(handleToggleMemoryDebug);
|
useMenuActions(handleToggleMemoryDebug);
|
||||||
|
|
||||||
useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled });
|
useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled });
|
||||||
useSessionAutoCleanup({ enabled: embeddedBackgroundWorkEnabled });
|
|
||||||
useQueuedMessageAutoSend({ enabled: embeddedBackgroundWorkEnabled });
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (embeddedSessionChat) {
|
if (embeddedSessionChat) {
|
||||||
@@ -397,14 +650,19 @@ function App({ apis }: AppProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
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();
|
e.preventDefault();
|
||||||
setShowMemoryDebug(prev => !prev);
|
setShowMemoryDebug(prev => !prev);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown, true);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||||
}, [embeddedSessionChat]);
|
}, [embeddedSessionChat]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -418,58 +676,141 @@ function App({ apis }: AppProps) {
|
|||||||
}
|
}
|
||||||
}, [clearError, embeddedSessionChat, error]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
if (embeddedSessionChat) {
|
if (!isDesktopRuntime || bootInjectionStatus !== 'not-injected') {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isDesktopShell() || !isDesktopLocalOriginActive()) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const run = async () => {
|
let attempts = 0;
|
||||||
const res = await fetch('/health', { method: 'GET' }).catch(() => null);
|
const BASE_INTERVAL = 200;
|
||||||
if (!res || !res.ok || cancelled) return;
|
const MAX_INTERVAL = 2000;
|
||||||
const data = (await res.json().catch(() => null)) as null | {
|
const MAX_ATTEMPTS = 50; // 10 seconds total (200ms * 50 with exponential backoff cap)
|
||||||
openCodeRunning?: unknown;
|
|
||||||
isOpenCodeReady?: unknown;
|
const pollWithBackoff = () => {
|
||||||
opencodeBinaryResolved?: unknown;
|
if (cancelled) return;
|
||||||
lastOpenCodeError?: unknown;
|
|
||||||
};
|
attempts++;
|
||||||
if (!data || cancelled) return;
|
const status = getBootInjectionStatus();
|
||||||
const openCodeRunning = data.openCodeRunning === true;
|
|
||||||
const isOpenCodeReady = data.isOpenCodeReady === true;
|
if (status !== 'not-injected') {
|
||||||
const resolvedBinary = typeof data.opencodeBinaryResolved === 'string' ? data.opencodeBinaryResolved.trim() : '';
|
cancelled = true;
|
||||||
const hasResolvedBinary = resolvedBinary.length > 0;
|
setBootInjectionStatus(status);
|
||||||
const err = typeof data.lastOpenCodeError === 'string' ? data.lastOpenCodeError : '';
|
|
||||||
const cliMissing =
|
if (status === 'valid') {
|
||||||
!openCodeRunning &&
|
const outcome = getInjectedBootOutcome();
|
||||||
(CLI_MISSING_ERROR_REGEX.test(err) || (!hasResolvedBinary && !isOpenCodeReady));
|
if (outcome) {
|
||||||
setShowCliOnboarding(cliMissing);
|
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();
|
// Start polling
|
||||||
const interval = window.setInterval(() => {
|
window.setTimeout(pollWithBackoff, BASE_INTERVAL);
|
||||||
void run();
|
|
||||||
}, CLI_ONBOARDING_HEALTH_POLL_MS);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
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();
|
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 (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<div className="h-full text-foreground bg-transparent">
|
<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>
|
</div>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
@@ -478,14 +819,37 @@ function App({ apis }: AppProps) {
|
|||||||
if (embeddedSessionChat) {
|
if (embeddedSessionChat) {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<RuntimeAPIProvider apis={apis}>
|
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
|
||||||
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
|
<RuntimeAPIProvider apis={apis}>
|
||||||
<div className="h-full text-foreground bg-background">
|
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
|
||||||
<ChatView />
|
<div className="h-full text-foreground bg-background">
|
||||||
<Toaster />
|
<EmbeddedSessionSelectionGate embeddedSessionChat={embeddedSessionChat} isVSCodeRuntime={isVSCodeRuntime} />
|
||||||
</div>
|
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
|
||||||
</TooltipProvider>
|
<ChatView />
|
||||||
</RuntimeAPIProvider>
|
<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>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -493,62 +857,79 @@ function App({ apis }: AppProps) {
|
|||||||
// VS Code runtime - simplified layout without git/terminal views
|
// VS Code runtime - simplified layout without git/terminal views
|
||||||
if (isVSCodeRuntime) {
|
if (isVSCodeRuntime) {
|
||||||
// Check if this is the Agent Manager panel
|
// Check if this is the Agent Manager panel
|
||||||
const panelType = typeof window !== 'undefined'
|
const panelType = typeof window !== 'undefined'
|
||||||
? (window as { __OPENCHAMBER_PANEL_TYPE__?: 'chat' | 'agentManager' }).__OPENCHAMBER_PANEL_TYPE__
|
? (window as { __OPENCHAMBER_PANEL_TYPE__?: 'chat' | 'agentManager' }).__OPENCHAMBER_PANEL_TYPE__
|
||||||
: 'chat';
|
: 'chat';
|
||||||
|
|
||||||
if (panelType === 'agentManager') {
|
if (panelType === 'agentManager') {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<RuntimeAPIProvider apis={apis}>
|
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
|
||||||
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
|
<RuntimeAPIProvider apis={apis}>
|
||||||
<div className="h-full text-foreground bg-background">
|
|
||||||
<AgentManagerView />
|
|
||||||
<Toaster />
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
|
||||||
</RuntimeAPIProvider>
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<RuntimeAPIProvider apis={apis}>
|
|
||||||
<FireworksProvider>
|
|
||||||
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
|
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
|
||||||
<div className="h-full text-foreground bg-background">
|
<div className="h-full text-foreground bg-background">
|
||||||
<VSCodeLayout />
|
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
|
||||||
|
<AgentManagerView />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</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>
|
</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 (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<RuntimeAPIProvider apis={apis}>
|
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
|
||||||
<GitPollingProvider>
|
<RuntimeAPIProvider apis={apis}>
|
||||||
<FireworksProvider>
|
<FireworksProvider>
|
||||||
<VoiceProvider>
|
<VoiceProvider>
|
||||||
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
|
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
|
||||||
<div className={isDesktopRuntime ? 'h-full text-foreground bg-transparent' : 'h-full text-foreground bg-background'}>
|
<div className={isDesktopRuntime ? 'h-full text-foreground bg-transparent' : 'h-full text-foreground bg-background'}>
|
||||||
|
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
|
||||||
<MainLayout />
|
<MainLayout />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<ConfigUpdateOverlay />
|
{!isBootShell && (
|
||||||
<AboutDialogWrapper />
|
<>
|
||||||
{showMemoryDebug && (
|
<ConfigUpdateOverlay />
|
||||||
<MemoryDebugPanel onClose={() => setShowMemoryDebug(false)} />
|
<QuickOpenDialog />
|
||||||
|
<AboutDialogWrapper />
|
||||||
|
{showMemoryDebug && (
|
||||||
|
<MemoryDebugPanel onClose={() => setShowMemoryDebug(false)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</VoiceProvider>
|
</VoiceProvider>
|
||||||
</FireworksProvider>
|
</FireworksProvider>
|
||||||
</GitPollingProvider>
|
</RuntimeAPIProvider>
|
||||||
</RuntimeAPIProvider>
|
</SyncProvider>
|
||||||
</ErrorBoundary>
|
</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">
|
<symbol id="jsconfig_light" viewBox="0 0 32 32">
|
||||||
<use href="#jsconfig" />
|
<use href="#jsconfig" />
|
||||||
</symbol>
|
</symbol>
|
||||||
<symbol id="json" viewBox="0 -960 960 960">
|
<symbol id="json" viewBox="0 0 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"/>
|
<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>
|
||||||
<symbol id="json_light" viewBox="0 -960 960 960">
|
<symbol id="json_light" viewBox="0 0 960 960">
|
||||||
<use href="#json" />
|
<use href="#json" />
|
||||||
</symbol>
|
</symbol>
|
||||||
<symbol id="jsr" viewBox="0 0 16 16">
|
<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 React from 'react';
|
||||||
import { RiLockLine, RiLockUnlockLine, RiLoader4Line } from '@remixicon/react';
|
import { RiLockLine, RiLockUnlockLine, RiLoader4Line } from '@remixicon/react';
|
||||||
|
import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { toast } from '@/components/ui';
|
||||||
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { syncDesktopSettings, initializeAppearancePreferences } from '@/lib/persistence';
|
import { syncDesktopSettings, initializeAppearancePreferences } from '@/lib/persistence';
|
||||||
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
|
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
|
||||||
import { DesktopHostSwitcherInline } from '@/components/desktop/DesktopHostSwitcher';
|
import { DesktopHostSwitcherInline } from '@/components/desktop/DesktopHostSwitcher';
|
||||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
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 STATUS_CHECK_ENDPOINT = '/auth/session';
|
||||||
|
const TRUST_DEVICE_STORAGE_KEY = 'openchamber.uiAuth.trustDevice';
|
||||||
|
|
||||||
const fetchSessionStatus = async (): Promise<Response> => {
|
const fetchSessionStatus = async (): Promise<Response> => {
|
||||||
console.log('[Frontend Auth] Checking session status...');
|
console.log('[Frontend Auth] Checking session status...');
|
||||||
@@ -23,7 +37,14 @@ const fetchSessionStatus = async (): Promise<Response> => {
|
|||||||
return 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...');
|
console.log('[Frontend Auth] Submitting password...');
|
||||||
const response = await fetch(STATUS_CHECK_ENDPOINT, {
|
const response = await fetch(STATUS_CHECK_ENDPOINT, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -32,7 +53,7 @@ const submitPassword = async (password: string): Promise<Response> => {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Accept: '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);
|
console.log('[Frontend Auth] Password submit response:', response.status, response.statusText);
|
||||||
return response;
|
return response;
|
||||||
@@ -64,11 +85,12 @@ const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|||||||
|
|
||||||
const LoadingScreen: React.FC = () => (
|
const LoadingScreen: React.FC = () => (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background text-foreground">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const ErrorScreen: React.FC<ErrorScreenProps> = ({ onRetry, errorType = 'network', retryAfter }) => {
|
const ErrorScreen: React.FC<ErrorScreenProps> = ({ onRetry, errorType = 'network', retryAfter }) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const isRateLimit = errorType === 'rate-limit';
|
const isRateLimit = errorType === 'rate-limit';
|
||||||
const minutes = retryAfter ? Math.ceil(retryAfter / 60) : 1;
|
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="flex flex-col items-center gap-6 text-center">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h1 className="typography-ui-header font-semibold text-destructive">
|
<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>
|
</h1>
|
||||||
<p className="typography-meta text-muted-foreground max-w-xs">
|
<p className="typography-meta text-muted-foreground max-w-xs">
|
||||||
{isRateLimit
|
{isRateLimit
|
||||||
? `Please wait ${minutes} minute${minutes > 1 ? 's' : ''} before trying again.`
|
? (minutes > 1
|
||||||
: "We couldn't verify the UI session. Check that the service is running and try again."}
|
? t('sessionAuth.error.rateLimitDescriptionPlural', { minutes })
|
||||||
|
: t('sessionAuth.error.rateLimitDescriptionSingle', { minutes }))
|
||||||
|
: t('sessionAuth.error.networkDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" onClick={onRetry} className="w-full max-w-xs">
|
<Button type="button" onClick={onRetry} className="w-full max-w-xs">
|
||||||
Retry
|
{t('sessionAuth.error.retry')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
@@ -106,6 +130,7 @@ interface ErrorScreenProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) => {
|
export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []);
|
const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []);
|
||||||
const skipAuth = vscodeRuntime;
|
const skipAuth = vscodeRuntime;
|
||||||
const showHostSwitcher = React.useMemo(() => isDesktopShell() && !vscodeRuntime, [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 [errorMessage, setErrorMessage] = React.useState('');
|
||||||
const [retryAfter, setRetryAfter] = React.useState<number | undefined>(undefined);
|
const [retryAfter, setRetryAfter] = React.useState<number | undefined>(undefined);
|
||||||
const [isTunnelLocked, setIsTunnelLocked] = React.useState(false);
|
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 passwordInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
const hasResyncedRef = React.useRef(skipAuth);
|
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 () => {
|
const checkStatus = React.useCallback(async () => {
|
||||||
if (skipAuth) {
|
if (skipAuth) {
|
||||||
console.log('[Frontend Auth] VSCode runtime, skipping auth');
|
console.log('[Frontend Auth] VSCode runtime, skipping auth');
|
||||||
@@ -125,16 +207,12 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
return;
|
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'));
|
setState((prev) => (prev === 'authenticated' ? prev : 'pending'));
|
||||||
try {
|
try {
|
||||||
const response = await fetchSessionStatus();
|
const [response, latestPasskeyStatus] = await Promise.all([
|
||||||
|
fetchSessionStatus(),
|
||||||
|
refreshPasskeyStatus(),
|
||||||
|
]);
|
||||||
const responseText = await response.text();
|
const responseText = await response.text();
|
||||||
console.log('[Frontend Auth] Raw response:', response.status, responseText);
|
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);
|
console.warn('[Frontend Auth] Debug info:', data.debug);
|
||||||
}
|
}
|
||||||
setIsTunnelLocked(data.tunnelLocked === true);
|
setIsTunnelLocked(data.tunnelLocked === true);
|
||||||
|
setPasskeyStatus(latestPasskeyStatus);
|
||||||
setState('locked');
|
setState('locked');
|
||||||
setRetryAfter(undefined);
|
setRetryAfter(undefined);
|
||||||
return;
|
return;
|
||||||
@@ -182,7 +261,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
setState('error');
|
setState('error');
|
||||||
setIsTunnelLocked(false);
|
setIsTunnelLocked(false);
|
||||||
}
|
}
|
||||||
}, [skipAuth]);
|
}, [refreshPasskeyStatus, skipAuth]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (skipAuth) {
|
if (skipAuth) {
|
||||||
@@ -220,6 +299,28 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
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) {
|
if (isTunnelLocked) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -227,28 +328,43 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPasskeyBusy) {
|
||||||
|
cancelActivePasskey();
|
||||||
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await submitPassword(password);
|
const response = await submitPassword(password, trustDevice);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
console.log('[Frontend Auth] Login successful');
|
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('');
|
setPassword('');
|
||||||
setIsTunnelLocked(false);
|
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');
|
setState('authenticated');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
console.warn('[Frontend Auth] Login failed: Invalid password');
|
console.warn('[Frontend Auth] Login failed: Invalid password');
|
||||||
setErrorMessage('Incorrect password. Try again.');
|
setErrorMessage(t('sessionAuth.error.incorrectPassword'));
|
||||||
setIsTunnelLocked(false);
|
setIsTunnelLocked(false);
|
||||||
setState('locked');
|
setState('locked');
|
||||||
return;
|
return;
|
||||||
@@ -264,18 +380,86 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.error('[Frontend Auth] Login failed: Unexpected response', response.status);
|
console.error('[Frontend Auth] Login failed: Unexpected response', response.status);
|
||||||
setErrorMessage('Unexpected response from server.');
|
setErrorMessage(t('sessionAuth.error.unexpectedResponse'));
|
||||||
setIsTunnelLocked(false);
|
setIsTunnelLocked(false);
|
||||||
setState('error');
|
setState('error');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to submit UI password:', error);
|
console.warn('Failed to submit UI password:', error);
|
||||||
setErrorMessage('Network error. Check connection and retry.');
|
setErrorMessage(t('sessionAuth.error.networkRetry'));
|
||||||
setIsTunnelLocked(false);
|
setIsTunnelLocked(false);
|
||||||
setState('error');
|
setState('error');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
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') {
|
if (state === 'pending') {
|
||||||
return <LoadingScreen />;
|
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-6 w-full max-w-xs">
|
||||||
<div className="flex flex-col items-center gap-1 text-center">
|
<div className="flex flex-col items-center gap-1 text-center">
|
||||||
<h1 className="text-xl font-semibold text-foreground">
|
<h1 className="text-xl font-semibold text-foreground">
|
||||||
{isTunnelLocked ? 'Tunnel access required' : 'Unlock OpenChamber'}
|
{isTunnelLocked ? t('sessionAuth.locked.tunnelTitle') : t('sessionAuth.locked.unlockTitle')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="typography-meta text-muted-foreground">
|
<p className="typography-meta text-muted-foreground">
|
||||||
{isTunnelLocked
|
{isTunnelLocked
|
||||||
? 'Open this tunnel using the one-time connect link from the desktop app.'
|
? t('sessionAuth.locked.tunnelDescription')
|
||||||
: 'This session is password-protected.'}
|
: t('sessionAuth.locked.passwordDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isTunnelLocked && (
|
{!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="flex items-center gap-2">
|
||||||
<div className="relative flex-1">
|
<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" />
|
<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}
|
ref={passwordInputRef}
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
placeholder="Enter password"
|
placeholder={t('sessionAuth.password.placeholder')}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setPassword(event.target.value);
|
setPassword(event.target.value);
|
||||||
@@ -332,7 +534,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
type="submit"
|
type="submit"
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={!password || isSubmitting}
|
disabled={!password || isSubmitting}
|
||||||
aria-label={isSubmitting ? 'Unlocking' : 'Unlock'}
|
aria-label={isSubmitting ? t('sessionAuth.actions.unlockingAria') : t('sessionAuth.actions.unlockAria')}
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||||
@@ -341,6 +543,45 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 && (
|
{errorMessage && (
|
||||||
<p id="oc-ui-auth-error" className="typography-meta text-destructive">
|
<p id="oc-ui-auth-error" className="typography-meta text-destructive">
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
@@ -353,7 +594,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<DesktopHostSwitcherInline />
|
<DesktopHostSwitcherInline />
|
||||||
<p className="mt-1 text-center typography-micro text-muted-foreground">
|
<p className="mt-1 text-center typography-micro text-muted-foreground">
|
||||||
Use Local if remote is unreachable.
|
{t('sessionAuth.locked.hostSwitcherHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { cn, fuzzyMatch } from '@/lib/utils';
|
|||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useAgentsStore, isAgentBuiltIn, type AgentWithExtras } from '@/stores/useAgentsStore';
|
import { useAgentsStore, isAgentBuiltIn, type AgentWithExtras } from '@/stores/useAgentsStore';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
interface AgentInfo {
|
interface AgentInfo {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -40,13 +41,15 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
|
|||||||
activeTab = 'agents',
|
activeTab = 'agents',
|
||||||
onTabSelect,
|
onTabSelect,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||||
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
|
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
|
||||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const ignoreTabClickRef = React.useRef(false);
|
const ignoreTabClickRef = React.useRef(false);
|
||||||
const { getVisibleAgents } = useConfigStore();
|
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
||||||
const { agents: agentsWithMetadata, loadAgents } = useAgentsStore();
|
const agentsWithMetadata = useAgentsStore((state) => state.agents);
|
||||||
|
const loadAgents = useAgentsStore((state) => state.loadAgents);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (agentsWithMetadata.length === 0) {
|
if (agentsWithMetadata.length === 0) {
|
||||||
@@ -156,7 +159,7 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
|
|||||||
<span className="font-semibold">#{agent.name}</span>
|
<span className="font-semibold">#{agent.name}</span>
|
||||||
{isSystem ? (
|
{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">
|
<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>
|
</span>
|
||||||
) : agent.scope ? (
|
) : agent.scope ? (
|
||||||
<span className={cn(
|
<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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
@@ -187,11 +196,7 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
|
|||||||
{showTabs ? (
|
{showTabs ? (
|
||||||
<div className="px-2 pt-2 pb-1 border-b border-border/60">
|
<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">
|
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-elevated)] p-1">
|
||||||
{([
|
{tabs.map((tab) => (
|
||||||
{ id: 'commands' as const, label: 'Commands' },
|
|
||||||
{ id: 'agents' as const, label: 'Agents' },
|
|
||||||
{ id: 'files' as const, label: 'Files' },
|
|
||||||
]).map((tab) => (
|
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -231,12 +236,12 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
||||||
No agents found
|
{t('chat.agentMentionAutocomplete.empty')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
|
<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>
|
||||||
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,45 +1,29 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||||
import { TextLoop } from '@/components/ui/TextLoop';
|
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
|
import { useGlobalSyncStore } from '@/sync/global-sync-store';
|
||||||
const phrases = [
|
import { useI18n } from '@/lib/i18n';
|
||||||
"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",
|
|
||||||
];
|
|
||||||
|
|
||||||
const ChatEmptyState: React.FC = () => {
|
const ChatEmptyState: React.FC = () => {
|
||||||
|
const { t } = useI18n();
|
||||||
const { currentTheme } = useThemeSystem();
|
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)';
|
const textColor = currentTheme?.colors?.surface?.mutedForeground || 'var(--muted-foreground)';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-full w-full gap-6">
|
<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 />
|
<OpenChamberLogo width={140} height={140} className="opacity-20" />
|
||||||
<TextLoop
|
{initError ? (
|
||||||
className="text-body-md"
|
<div className="flex flex-col items-center gap-2 max-w-md text-center px-4">
|
||||||
interval={4}
|
<span className="text-body-md font-medium text-destructive">{t('chat.emptyState.opencodeUnreachable')}</span>
|
||||||
transition={{ duration: 0.5 }}
|
<span className="text-body-sm" style={{ color: textColor }}>
|
||||||
>
|
{initError.message}
|
||||||
{phrases.map((phrase) => (
|
</span>
|
||||||
<span key={phrase} style={{ color: textColor }}>"{phrase}…"</span>
|
</div>
|
||||||
))}
|
) : (
|
||||||
</TextLoop>
|
<span className="text-body-md" style={{ color: textColor }}>{t('chat.emptyState.startNewChat')}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { RiChat3Line, RiRestartLine } from '@remixicon/react';
|
import { RiChat3Line, RiRestartLine } from '@remixicon/react';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
interface ChatErrorBoundaryState {
|
interface ChatErrorBoundaryState {
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
@@ -14,8 +15,21 @@ interface ChatErrorBoundaryProps {
|
|||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, ChatErrorBoundaryState> {
|
interface ChatErrorBoundaryTexts {
|
||||||
constructor(props: ChatErrorBoundaryProps) {
|
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);
|
super(props);
|
||||||
this.state = { hasError: false };
|
this.state = { hasError: false };
|
||||||
}
|
}
|
||||||
@@ -44,24 +58,24 @@ export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, C
|
|||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<CardTitle className="flex items-center justify-center gap-2 text-destructive">
|
<CardTitle className="flex items-center justify-center gap-2 text-destructive">
|
||||||
<RiChat3Line className="h-5 w-5" />
|
<RiChat3Line className="h-5 w-5" />
|
||||||
Chat Error
|
{this.props.texts.title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
<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>
|
</p>
|
||||||
|
|
||||||
{this.props.sessionId && (
|
{this.props.sessionId && (
|
||||||
<div className="text-xs text-muted-foreground text-center">
|
<div className="text-xs text-muted-foreground text-center">
|
||||||
Session: {this.props.sessionId}
|
{this.props.texts.sessionLabel}: {this.props.sessionId}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{this.state.error && (
|
{this.state.error && (
|
||||||
<details className="text-xs font-mono bg-muted p-3 rounded">
|
<details className="text-xs font-mono bg-muted p-3 rounded">
|
||||||
<summary className="cursor-pointer hover:bg-interactive-hover/80">Error details</summary>
|
<summary className="cursor-pointer hover:bg-interactive-hover/80">{this.props.texts.detailsSummary}</summary>
|
||||||
<pre className="mt-2 overflow-x-auto">
|
<pre className="mt-2 max-h-48 overflow-auto">
|
||||||
{this.state.error.toString()}
|
{this.state.error.toString()}
|
||||||
</pre>
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
@@ -70,12 +84,12 @@ export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, C
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={this.handleReset} variant="outline" className="flex-1">
|
<Button onClick={this.handleReset} variant="outline" className="flex-1">
|
||||||
<RiRestartLine className="h-4 w-4 mr-2" />
|
<RiRestartLine className="h-4 w-4 mr-2" />
|
||||||
Reset Chat
|
{this.props.texts.resetAction}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-muted-foreground text-center">
|
<div className="text-xs text-muted-foreground text-center">
|
||||||
If the problem persists, try refreshing the page.
|
{this.props.texts.persistentHint}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -86,3 +100,20 @@ export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, C
|
|||||||
return this.props.children;
|
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'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import { useShallow } from 'zustand/react/shallow';
|
|||||||
|
|
||||||
import { defaultCodeDark, defaultCodeLight } from '@/lib/codeTheme';
|
import { defaultCodeDark, defaultCodeLight } from '@/lib/codeTheme';
|
||||||
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
|
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
|
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useContextStore } from '@/stores/contextStore';
|
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 { useDeviceInfo } from '@/lib/device';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
||||||
@@ -23,11 +26,14 @@ import { filterVisibleParts } from './message/partUtils';
|
|||||||
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
|
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
|
||||||
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
||||||
import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
|
import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
|
||||||
|
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||||
import type { TurnGroupingContext } from './lib/turns/types';
|
import type { TurnGroupingContext } from './lib/turns/types';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
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 EXPANDED_TOOLS_CACHE_MAX = 4000;
|
||||||
const expandedToolsStateCache = new Map<string, Set<string>>();
|
const expandedToolsStateCache = new Map<string, Set<string>>();
|
||||||
@@ -123,6 +129,9 @@ interface ChatMessageProps {
|
|||||||
animationHandlers?: AnimationHandlers;
|
animationHandlers?: AnimationHandlers;
|
||||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||||
turnGroupingContext?: TurnGroupingContext;
|
turnGroupingContext?: TurnGroupingContext;
|
||||||
|
assistantHeaderMessageId?: string;
|
||||||
|
isInActiveTurn?: boolean;
|
||||||
|
activeStreamingPhase?: StreamPhase | null;
|
||||||
animateUserOnMount?: boolean;
|
animateUserOnMount?: boolean;
|
||||||
onUserAnimationConsumed?: (messageId: string) => void;
|
onUserAnimationConsumed?: (messageId: string) => void;
|
||||||
}
|
}
|
||||||
@@ -134,6 +143,9 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
onContentChange,
|
onContentChange,
|
||||||
animationHandlers,
|
animationHandlers,
|
||||||
turnGroupingContext,
|
turnGroupingContext,
|
||||||
|
assistantHeaderMessageId,
|
||||||
|
isInActiveTurn = false,
|
||||||
|
activeStreamingPhase = null,
|
||||||
animateUserOnMount = false,
|
animateUserOnMount = false,
|
||||||
onUserAnimationConsumed,
|
onUserAnimationConsumed,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -141,36 +153,19 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
const { currentTheme } = useThemeSystem();
|
const { currentTheme } = useThemeSystem();
|
||||||
const messageContainerRef = React.useRef<HTMLDivElement | null>(null);
|
const messageContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const sessionState = useSessionStore(
|
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||||
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 {
|
const getAgentModelForSession = useSelectionStore((s) => s.getAgentModelForSession);
|
||||||
lifecyclePhase,
|
const getSessionModelSelection = useSelectionStore((s) => s.getSessionModelSelection);
|
||||||
isStreamingMessage,
|
const revertToMessage = sessionActions.revertToMessage;
|
||||||
currentSessionId,
|
const forkFromMessage = sessionActions.forkFromMessage;
|
||||||
getAgentModelForSession,
|
|
||||||
getSessionModelSelection,
|
|
||||||
revertToMessage,
|
|
||||||
forkFromMessage,
|
|
||||||
} = sessionState;
|
|
||||||
|
|
||||||
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(
|
const { showReasoningTraces, stickyUserHeader, chatRenderMode, showExpandedBashTools, showExpandedEditTools } = useUIStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
showReasoningTraces: state.showReasoningTraces,
|
showReasoningTraces: state.showReasoningTraces,
|
||||||
@@ -210,12 +205,13 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
const showStickyInlineHoverRow = isUser && !isMobile && stickyUserHeader && !useExternalUserActionsRow;
|
const showStickyInlineHoverRow = isUser && !isMobile && stickyUserHeader && !useExternalUserActionsRow;
|
||||||
|
|
||||||
const sessionId = message.info.sessionID;
|
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(
|
const { currentContextAgent, savedSessionAgentSelection } = useContextStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
currentContextAgent: sessionId ? state.currentAgentContext.get(sessionId) : undefined,
|
currentContextAgent: isInActiveTurn && sessionId ? state.currentAgentContext.get(sessionId) : undefined,
|
||||||
savedSessionAgentSelection: sessionId ? state.sessionAgentSelections.get(sessionId) : undefined,
|
savedSessionAgentSelection: isInActiveTurn && sessionId ? state.sessionAgentSelections.get(sessionId) : undefined,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -224,8 +220,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
return message.parts;
|
return message.parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizeUserDisplayParts(message.parts);
|
return normalizeUserDisplayParts(message.parts, { planModeEnabled });
|
||||||
}, [isUser, message.parts]);
|
}, [isUser, message.parts, planModeEnabled]);
|
||||||
|
|
||||||
const previousUserMetadata = React.useMemo(() => {
|
const previousUserMetadata = React.useMemo(() => {
|
||||||
if (isUser || !previousMessage) {
|
if (isUser || !previousMessage) {
|
||||||
@@ -265,6 +261,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
}, [isUser, previousMessage]);
|
}, [isUser, previousMessage]);
|
||||||
|
|
||||||
const previousIsModeSwitchMessage = React.useMemo(() => {
|
const previousIsModeSwitchMessage = React.useMemo(() => {
|
||||||
|
if (!planModeEnabled) return false;
|
||||||
if (isUser || !previousMessage) return false;
|
if (isUser || !previousMessage) return false;
|
||||||
const parts = Array.isArray(previousMessage.parts) ? previousMessage.parts : [];
|
const parts = Array.isArray(previousMessage.parts) ? previousMessage.parts : [];
|
||||||
for (let i = 0; i < parts.length; i++) {
|
for (let i = 0; i < parts.length; i++) {
|
||||||
@@ -277,7 +274,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}, [isUser, previousMessage]);
|
}, [isUser, planModeEnabled, previousMessage]);
|
||||||
|
|
||||||
const agentName = React.useMemo(() => {
|
const agentName = React.useMemo(() => {
|
||||||
if (isUser) return undefined;
|
if (isUser) return undefined;
|
||||||
@@ -589,11 +586,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
if (isMessageCompleted) {
|
if (isMessageCompleted) {
|
||||||
return 'completed';
|
return 'completed';
|
||||||
}
|
}
|
||||||
if (lifecyclePhase) {
|
if (isInActiveTurn) {
|
||||||
return lifecyclePhase;
|
return activeStreamingPhase ?? 'streaming';
|
||||||
}
|
}
|
||||||
return isStreamingMessage ? 'streaming' : 'completed';
|
return 'completed';
|
||||||
}, [isMessageCompleted, lifecyclePhase, isStreamingMessage]);
|
}, [activeStreamingPhase, isInActiveTurn, isMessageCompleted]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isUser || !animateUserOnMount) {
|
if (!isUser || !animateUserOnMount) {
|
||||||
@@ -607,7 +604,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
}, [message.info.id]);
|
}, [message.info.id]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const headerMessageId = turnGroupingContext?.headerMessageId;
|
const headerMessageId = assistantHeaderMessageId ?? turnGroupingContext?.headerMessageId;
|
||||||
if (isUser || !headerMessageId || headerMessageId !== message.info.id) {
|
if (isUser || !headerMessageId || headerMessageId !== message.info.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -616,13 +613,13 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
if (isCurrentlyStreaming) {
|
if (isCurrentlyStreaming) {
|
||||||
setHasStartedStreamingHeader(true);
|
setHasStartedStreamingHeader(true);
|
||||||
}
|
}
|
||||||
}, [isUser, message.info.id, streamPhase, turnGroupingContext?.headerMessageId]);
|
}, [assistantHeaderMessageId, isUser, message.info.id, streamPhase, turnGroupingContext?.headerMessageId]);
|
||||||
|
|
||||||
const shouldShowHeader = React.useMemo(() => {
|
const shouldShowHeader = React.useMemo(() => {
|
||||||
if (isUser) return true;
|
if (isUser) return true;
|
||||||
|
|
||||||
// Use turn grouping context if available for more precise control
|
// Use turn grouping context if available for more precise control
|
||||||
const headerMessageId = turnGroupingContext?.headerMessageId;
|
const headerMessageId = assistantHeaderMessageId ?? turnGroupingContext?.headerMessageId;
|
||||||
if (headerMessageId) {
|
if (headerMessageId) {
|
||||||
// For turn grouping: only show header for the first assistant message in the turn
|
// For turn grouping: only show header for the first assistant message in the turn
|
||||||
const isFirstAssistantInTurn = message.info.id === headerMessageId;
|
const isFirstAssistantInTurn = message.info.id === headerMessageId;
|
||||||
@@ -644,7 +641,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
|
|
||||||
// Ungrouped fallback path: always show assistant header.
|
// Ungrouped fallback path: always show assistant header.
|
||||||
return true;
|
return true;
|
||||||
}, [hasStartedStreamingHeader, isUser, turnGroupingContext, streamPhase, message.info.id]);
|
}, [assistantHeaderMessageId, hasStartedStreamingHeader, isUser, turnGroupingContext, streamPhase, message.info.id]);
|
||||||
|
|
||||||
const handleCopyCode = React.useCallback((code: string) => {
|
const handleCopyCode = React.useCallback((code: string) => {
|
||||||
void copyTextToClipboard(code).then((result) => {
|
void copyTextToClipboard(code).then((result) => {
|
||||||
@@ -738,11 +735,14 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
const handleCopyMessage = React.useCallback(async () => {
|
const handleCopyMessage = React.useCallback(async () => {
|
||||||
const result = await copyTextToClipboard(messageTextContent);
|
const result = await copyTextToClipboard(messageTextContent);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
setCopiedMessage(true);
|
if (isUser) {
|
||||||
setTimeout(() => setCopiedMessage(false), 2000);
|
setCopiedMessage(true);
|
||||||
}, [messageTextContent]);
|
setTimeout(() => setCopiedMessage(false), 2000);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, [isUser, messageTextContent]);
|
||||||
|
|
||||||
const handleRevert = React.useCallback(() => {
|
const handleRevert = React.useCallback(() => {
|
||||||
if (!sessionId || !message.info.id) return;
|
if (!sessionId || !message.info.id) return;
|
||||||
@@ -971,7 +971,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
const assistantTopPaddingClass = !isUser && shouldShowHeader
|
const assistantTopPaddingClass = !isUser && shouldShowHeader
|
||||||
? (stickyUserHeader ? (isMobile ? 'pt-4' : 'pt-6') : 'pt-0')
|
? (stickyUserHeader ? (isMobile ? 'pt-4' : 'pt-6') : 'pt-0')
|
||||||
: 'pt-0';
|
: 'pt-0';
|
||||||
const userMessageRadius = 'var(--radius-lg)';
|
const userMessageRadius = 'var(--radius-xl)';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -995,7 +995,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
respectReducedMotion
|
respectReducedMotion
|
||||||
>
|
>
|
||||||
<div className={cn('relative flex justify-end', !isMobile ? 'group/user-shell' : undefined)}>
|
<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
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--chat-user-message-bg)',
|
backgroundColor: 'var(--chat-user-message-bg)',
|
||||||
@@ -1068,8 +1068,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{showStickyInlineHoverRow ? <div aria-hidden="true" className="pointer-events-none absolute left-0 right-0 top-full h-11" /> : null}
|
</div>
|
||||||
</div>
|
|
||||||
</FadeInOnReveal>
|
</FadeInOnReveal>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
@@ -1086,6 +1085,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<MessageBody
|
<MessageBody
|
||||||
|
sessionId={message.info.sessionID}
|
||||||
messageId={message.info.id}
|
messageId={message.info.id}
|
||||||
parts={visibleParts}
|
parts={visibleParts}
|
||||||
isUser={isUser}
|
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 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 { 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 { useCommandsStore } from '@/stores/useCommandsStore';
|
||||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
interface CommandInfo {
|
type CommandSource = 'openchamber' | 'opencode';
|
||||||
|
|
||||||
|
export interface CommandInfo {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
source: CommandSource;
|
||||||
description?: string;
|
description?: string;
|
||||||
agent?: string;
|
agent?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
isBuiltIn?: boolean;
|
isBuiltIn?: boolean;
|
||||||
|
isOpenChamber?: boolean;
|
||||||
isSkill?: boolean;
|
isSkill?: boolean;
|
||||||
scope?: string;
|
scope?: string;
|
||||||
}
|
}
|
||||||
@@ -42,22 +48,20 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
onTabSelect,
|
onTabSelect,
|
||||||
style,
|
style,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const { hasMessagesInCurrentSession, currentSessionId } = useSessionStore(
|
const { t } = useI18n();
|
||||||
useShallow((state) => {
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const sessionId = state.currentSessionId;
|
const sessionMessages = useSessionMessages(currentSessionId ?? '');
|
||||||
const messageCount = sessionId ? (state.messages.get(sessionId)?.length ?? 0) : 0;
|
const hasMessagesInCurrentSession = sessionMessages.length > 0;
|
||||||
return {
|
|
||||||
hasMessagesInCurrentSession: messageCount > 0,
|
|
||||||
currentSessionId: sessionId,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const hasSession = Boolean(currentSessionId);
|
const hasSession = Boolean(currentSessionId);
|
||||||
|
const hasNewSessionDraft = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||||
|
const canStartSessionCommand = hasSession || hasNewSessionDraft;
|
||||||
|
|
||||||
const [commands, setCommands] = React.useState<CommandInfo[]>([]);
|
const [commands, setCommands] = React.useState<CommandInfo[]>([]);
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
const { commands: commandsWithMetadata, loadCommands: refreshCommands } = useCommandsStore();
|
const commandsWithMetadata = useCommandsStore((s) => s.commands);
|
||||||
const { skills, loadSkills: refreshSkills } = useSkillsStore();
|
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 [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
@@ -95,8 +99,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const skillNames = new Set(skills.map((skill) => skill.name));
|
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,
|
name: cmd.name,
|
||||||
|
source: 'opencode',
|
||||||
description: cmd.description,
|
description: cmd.description,
|
||||||
agent: cmd.agent ?? undefined,
|
agent: cmd.agent ?? undefined,
|
||||||
model: cmd.model ?? undefined,
|
model: cmd.model ?? undefined,
|
||||||
@@ -107,27 +113,27 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
|
|
||||||
const builtInCommands: CommandInfo[] = [
|
const builtInCommands: CommandInfo[] = [
|
||||||
...(hasSession && !hasMessagesInCurrentSession
|
...(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
|
...(hasSession // Show when session exists, not when hasMessages
|
||||||
? [
|
? [
|
||||||
{ name: 'undo', description: 'Undo the last message', isBuiltIn: true },
|
{ id: 'openchamber:undo', name: 'undo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.undoDescription'), isBuiltIn: true },
|
||||||
{ name: 'redo', description: 'Redo previously undone messages', isBuiltIn: true },
|
{ id: 'openchamber:redo', name: 'redo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.redoDescription'), isBuiltIn: true },
|
||||||
{ name: 'timeline', description: 'Jump to a specific message', 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 allCommands = [...builtInCommands, ...customCommands];
|
||||||
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 allowInitCommand = !hasMessagesInCurrentSession;
|
const allowInitCommand = !hasMessagesInCurrentSession;
|
||||||
const filtered = (searchQuery
|
const filtered = (searchQuery
|
||||||
@@ -151,18 +157,25 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
const allowInitCommand = !hasMessagesInCurrentSession;
|
const allowInitCommand = !hasMessagesInCurrentSession;
|
||||||
const builtInCommands: CommandInfo[] = [
|
const builtInCommands: CommandInfo[] = [
|
||||||
...(hasSession && !hasMessagesInCurrentSession
|
...(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
|
...(hasSession // Show when session exists, not when hasMessages
|
||||||
? [
|
? [
|
||||||
{ name: 'undo', description: 'Undo the last message', isBuiltIn: true },
|
{ id: 'openchamber:undo', name: 'undo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.undoDescription'), isBuiltIn: true },
|
||||||
{ name: 'redo', description: 'Redo previously undone messages', isBuiltIn: true },
|
{ id: 'openchamber:redo', name: 'redo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.redoDescription'), isBuiltIn: true },
|
||||||
{ name: 'timeline', description: 'Jump to a specific message', 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
|
const filtered = (searchQuery
|
||||||
@@ -179,7 +192,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadCommands();
|
loadCommands();
|
||||||
}, [searchQuery, hasMessagesInCurrentSession, hasSession, commandsWithMetadata, skills]);
|
}, [searchQuery, hasMessagesInCurrentSession, hasSession, canStartSessionCommand, commandsWithMetadata, skills, t]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setSelectedIndex(0);
|
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" />;
|
return <RiArrowGoBackLine className="h-3.5 w-3.5 text-orange-500" />;
|
||||||
case 'redo':
|
case 'redo':
|
||||||
return <RiArrowGoForwardLine className="h-3.5 w-3.5 text-orange-500" />;
|
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':
|
case 'compact':
|
||||||
return <RiScissorsLine className="h-3.5 w-3.5 text-purple-500" />;
|
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 'test':
|
||||||
case 'build':
|
case 'build':
|
||||||
case 'run':
|
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="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">
|
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-elevated)] p-1">
|
||||||
{([
|
{([
|
||||||
{ id: 'commands' as const, label: 'Commands' },
|
{ id: 'commands' as const, label: t('chat.autocomplete.tabs.commands') },
|
||||||
{ id: 'agents' as const, label: 'Agents' },
|
{ id: 'agents' as const, label: t('chat.autocomplete.tabs.agents') },
|
||||||
{ id: 'files' as const, label: 'Files' },
|
{ id: 'files' as const, label: t('chat.autocomplete.tabs.files') },
|
||||||
]).map((tab) => (
|
]).map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
@@ -304,11 +317,12 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
<div>
|
<div>
|
||||||
{commands.map((command, index) => {
|
{commands.map((command, index) => {
|
||||||
const isSystem = command.isBuiltIn;
|
const isSystem = command.isBuiltIn;
|
||||||
|
const isOpenChamberBadge = command.isOpenChamber;
|
||||||
const isProject = command.scope === 'project';
|
const isProject = command.scope === 'project';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={command.name}
|
key={command.id}
|
||||||
ref={(el) => { itemRefs.current[index] = el; }}
|
ref={(el) => { itemRefs.current[index] = el; }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-start gap-2 px-3 py-2 cursor-pointer rounded-lg",
|
"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>
|
<span className="typography-ui-label font-medium">/{command.name}</span>
|
||||||
{command.isSkill ? (
|
{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">
|
<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>
|
</span>
|
||||||
) : null}
|
) : 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">
|
<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>
|
</span>
|
||||||
) : command.scope ? (
|
) : command.scope ? (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
@@ -401,19 +426,17 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
})}
|
})}
|
||||||
{commands.length === 0 && (
|
{commands.length === 0 && (
|
||||||
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
||||||
No commands found
|
{t('chat.commandAutocomplete.empty')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
CommandAutocomplete.displayName = 'CommandAutocomplete';
|
CommandAutocomplete.displayName = 'CommandAutocomplete';
|
||||||
|
|
||||||
export type { CommandInfo };
|
|
||||||
|
|||||||
@@ -4,6 +4,23 @@ import { cn } from '@/lib/utils';
|
|||||||
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||||
import { parseDiffToUnified } from './message/toolRenderers';
|
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 {
|
interface DiffPreviewProps {
|
||||||
diff: string;
|
diff: string;
|
||||||
syntaxTheme: { [key: string]: React.CSSProperties };
|
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||||
@@ -46,21 +63,8 @@ export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, syntaxTheme, fil
|
|||||||
PreTag="div"
|
PreTag="div"
|
||||||
wrapLines
|
wrapLines
|
||||||
wrapLongLines
|
wrapLongLines
|
||||||
customStyle={{
|
customStyle={DIFF_CUSTOM_STYLE}
|
||||||
margin: 0,
|
codeTagProps={DIFF_CODE_TAG_PROPS}
|
||||||
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' },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{line.content}
|
{line.content}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
@@ -104,21 +108,8 @@ export const WritePreview: React.FC<WritePreviewProps> = ({ content, syntaxTheme
|
|||||||
PreTag="div"
|
PreTag="div"
|
||||||
wrapLines
|
wrapLines
|
||||||
wrapLongLines
|
wrapLongLines
|
||||||
customStyle={{
|
customStyle={DIFF_CUSTOM_STYLE}
|
||||||
margin: 0,
|
codeTagProps={DIFF_CODE_TAG_PROPS}
|
||||||
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' },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{line || ' '}
|
{line || ' '}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useRef, memo } from 'react';
|
import React, { useRef, memo } from 'react';
|
||||||
import { RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiGithubLine, RiGitPullRequestLine } from '@remixicon/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 { useUIStore } from '@/stores/useUIStore';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -8,13 +9,15 @@ import { openExternalUrl } from '@/lib/url';
|
|||||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||||
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
|
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
|
||||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
import type { ToolPopupContent } from './message/types';
|
import type { ToolPopupContent } from './message/types';
|
||||||
|
|
||||||
export const FileAttachmentButton = memo(() => {
|
export const FileAttachmentButton = memo(() => {
|
||||||
|
const { t } = useI18n();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { addAttachedFile } = useSessionStore();
|
const addAttachedFile = useInputStore((state) => state.addAttachedFile);
|
||||||
const { isMobile } = useUIStore();
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const isVSCodeRuntime = useIsVSCodeRuntime();
|
const isVSCodeRuntime = useIsVSCodeRuntime();
|
||||||
const buttonSizeClass = isMobile ? 'h-9 w-9' : 'h-7 w-7';
|
const buttonSizeClass = isMobile ? 'h-9 w-9' : 'h-7 w-7';
|
||||||
const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]';
|
const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]';
|
||||||
@@ -26,7 +29,7 @@ export const FileAttachmentButton = memo(() => {
|
|||||||
await addAttachedFile(file);
|
await addAttachedFile(file);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('File attach failed', 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 : [];
|
const skipped = Array.isArray(data?.skipped) ? data.skipped : [];
|
||||||
|
|
||||||
if (skipped.length > 0) {
|
if (skipped.length > 0) {
|
||||||
const summary = skipped.map((s: { name?: string; reason?: string }) => `${s?.name || 'file'}: ${s?.reason || 'skipped'}`).join('\n');
|
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(`Some files were skipped:\n${summary}`);
|
toast.error(t('chat.fileAttachment.toast.someFilesSkipped', { summary }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const asFiles = picked
|
const asFiles = picked
|
||||||
@@ -66,7 +69,7 @@ export const FileAttachmentButton = memo(() => {
|
|||||||
bytes[i] = binary.charCodeAt(i);
|
bytes[i] = binary.charCodeAt(i);
|
||||||
}
|
}
|
||||||
const blob = new Blob([bytes], { type: mime });
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to decode VS Code picked file', err);
|
console.error('Failed to decode VS Code picked file', err);
|
||||||
return null;
|
return null;
|
||||||
@@ -79,7 +82,7 @@ export const FileAttachmentButton = memo(() => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('VS Code file pick failed', 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',
|
'hover:bg-muted text-muted-foreground',
|
||||||
buttonSizeClass
|
buttonSizeClass
|
||||||
)}
|
)}
|
||||||
aria-label="Attach files"
|
aria-label={t('chat.fileAttachment.actions.attachAria')}
|
||||||
>
|
>
|
||||||
<RiAttachment2 className={iconSizeClass} />
|
<RiAttachment2 className={iconSizeClass} />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
<p>Attach files</p>
|
<p>{t('chat.fileAttachment.actions.attach')}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
@@ -123,6 +126,7 @@ interface ImagePreviewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
|
const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const isLocalImagePreview =
|
const isLocalImagePreview =
|
||||||
file.source !== 'server' &&
|
file.source !== 'server' &&
|
||||||
file.mimeType.startsWith('image/') &&
|
file.mimeType.startsWith('image/') &&
|
||||||
@@ -162,7 +166,7 @@ const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
|
|||||||
onRemove();
|
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"
|
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" />
|
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
|
||||||
</span>
|
</span>
|
||||||
@@ -181,8 +185,8 @@ const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
|
|||||||
<button
|
<button
|
||||||
onClick={onRemove}
|
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"
|
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"
|
title={t('chat.fileAttachment.actions.removeImage')}
|
||||||
aria-label={`Remove ${displayName}`}
|
aria-label={t('chat.fileAttachment.actions.removeNamed', { name: displayName })}
|
||||||
>
|
>
|
||||||
<RiCloseLine className="h-2.5 w-2.5" />
|
<RiCloseLine className="h-2.5 w-2.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -198,6 +202,7 @@ interface FileChipProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FileChip = memo(({ file, onRemove }: FileChipProps) => {
|
const FileChip = memo(({ file, onRemove }: FileChipProps) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const getFileExtension = (filename: string): string => {
|
const getFileExtension = (filename: string): string => {
|
||||||
const parts = filename.split('.');
|
const parts = filename.split('.');
|
||||||
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
|
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
|
||||||
@@ -244,7 +249,7 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => {
|
|||||||
onRemove();
|
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"
|
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" />
|
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
|
||||||
</span>
|
</span>
|
||||||
@@ -255,7 +260,8 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => {
|
|||||||
FileChip.displayName = 'FileChip';
|
FileChip.displayName = 'FileChip';
|
||||||
|
|
||||||
export const AttachedFilesList = memo(() => {
|
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');
|
const localFiles = attachedFiles.filter((file) => file.source !== 'server');
|
||||||
|
|
||||||
@@ -516,7 +522,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
|||||||
if (isImage && file.url) {
|
if (isImage && file.url) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={file.url || `${fileName}-${index}`}
|
||||||
className="relative aspect-video rounded-lg border border-border/40 bg-muted/10 overflow-hidden group"
|
className="relative aspect-video rounded-lg border border-border/40 bg-muted/10 overflow-hidden group"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -536,7 +542,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
|||||||
|
|
||||||
if (githubLinkKind && file.url) {
|
if (githubLinkKind && file.url) {
|
||||||
return (
|
return (
|
||||||
<Tooltip key={index}>
|
<Tooltip key={file.url || `${fileName}-${index}`}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -569,7 +575,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip key={index}>
|
<Tooltip key={file.url || `${fileName}-${index}`}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -640,7 +646,7 @@ export const ImageGallery = memo(({ urls, caption, onShowPopup }: ImageGalleryPr
|
|||||||
<div className={cn("grid gap-2", getGridCols())}>
|
<div className={cn("grid gap-2", getGridCols())}>
|
||||||
{urls.map((url, index) => (
|
{urls.map((url, index) => (
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={url}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onShowPopup?.({
|
onClick={() => onShowPopup?.({
|
||||||
open: true,
|
open: true,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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 { cn, truncatePathMiddle } from '@/lib/utils';
|
||||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
@@ -11,6 +11,7 @@ import type { ProjectFileSearchHit } from '@/lib/opencode/client';
|
|||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
||||||
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type FileInfo = ProjectFileSearchHit;
|
type FileInfo = ProjectFileSearchHit;
|
||||||
type AgentInfo = {
|
type AgentInfo = {
|
||||||
@@ -18,6 +19,8 @@ type AgentInfo = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
mode?: string | null;
|
mode?: string | null;
|
||||||
};
|
};
|
||||||
|
const EMPTY_FILES: FileInfo[] = [];
|
||||||
|
const EMPTY_AGENTS: AgentInfo[] = [];
|
||||||
|
|
||||||
export interface FileMentionHandle {
|
export interface FileMentionHandle {
|
||||||
handleKeyDown: (key: string) => void;
|
handleKeyDown: (key: string) => void;
|
||||||
@@ -46,6 +49,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
onTabSelect,
|
onTabSelect,
|
||||||
style,
|
style,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const currentDirectory = useChatSearchDirectory() ?? '';
|
const currentDirectory = useChatSearchDirectory() ?? '';
|
||||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||||
const activeProjectPath = useProjectsStore(
|
const activeProjectPath = useProjectsStore(
|
||||||
@@ -64,14 +68,16 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
[projectRoot],
|
[projectRoot],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const { getVisibleAgents } = useConfigStore();
|
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
||||||
const searchFiles = useFileSearchStore((state) => state.searchFiles);
|
const searchFiles = useFileSearchStore((state) => state.searchFiles);
|
||||||
const debouncedQuery = useDebouncedValue(searchQuery, 180);
|
const debouncedQuery = useDebouncedValue(searchQuery, 180);
|
||||||
const showHidden = useDirectoryShowHidden();
|
const showHidden = useDirectoryShowHidden();
|
||||||
const showGitignored = useFilesViewShowGitignored();
|
const showGitignored = useFilesViewShowGitignored();
|
||||||
const [files, setFiles] = React.useState<FileInfo[]>([]);
|
const [files, setFiles] = React.useState<FileInfo[]>([]);
|
||||||
|
const [directories, setDirectories] = React.useState<FileInfo[]>([]);
|
||||||
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
|
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const pendingSearchRef = React.useRef(0);
|
||||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||||
const [marqueeWidth, setMarqueeWidth] = React.useState(360);
|
const [marqueeWidth, setMarqueeWidth] = React.useState(360);
|
||||||
const [overflowMap, setOverflowMap] = React.useState<Record<number, boolean>>({});
|
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 containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const ignoreTabClickRef = React.useRef(false);
|
const ignoreTabClickRef = React.useRef(false);
|
||||||
const normalizedSearchQuery = (searchQuery ?? '').trim();
|
const normalizedSearchQuery = (searchQuery ?? '').trim();
|
||||||
const visibleAgents = normalizedSearchQuery.length > 0 ? agents : agents.slice(0, 2);
|
const scopeResultsToActiveTab = showTabs === true;
|
||||||
|
|
||||||
const recentFiles = React.useMemo(() => {
|
const recentFiles = React.useMemo(() => {
|
||||||
if (!projectRoot || !projectTabs) {
|
if (!projectRoot || !projectTabs) {
|
||||||
return [] as FileInfo[];
|
return [] as FileInfo[];
|
||||||
@@ -121,6 +126,15 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
|
|
||||||
return mapped;
|
return mapped;
|
||||||
}, [normalizedSearchQuery, projectRoot, projectTabs]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||||
@@ -143,7 +157,6 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!currentDirectory) {
|
if (!currentDirectory) {
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
setLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,11 +168,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
|
|
||||||
if (!normalizedQueryLower) {
|
if (!normalizedQueryLower) {
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
setLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
pendingSearchRef.current++;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
searchFiles(currentDirectory, normalizedQueryLower, 80, {
|
searchFiles(currentDirectory, normalizedQueryLower, 80, {
|
||||||
@@ -182,15 +195,78 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setLoading(false);
|
pendingSearchRef.current--;
|
||||||
|
if (pendingSearchRef.current <= 0) {
|
||||||
|
pendingSearchRef.current = 0;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
pendingSearchRef.current = Math.max(0, pendingSearchRef.current - 1);
|
||||||
|
if (pendingSearchRef.current <= 0) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [currentDirectory, debouncedQuery, recentFiles, searchFiles, showHidden, showGitignored]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
const visibleAgents = getVisibleAgents();
|
const visibleAgents = getVisibleAgents();
|
||||||
const normalizedQuery = (searchQuery ?? '').trim().toLowerCase();
|
const normalizedQuery = (searchQuery ?? '').trim().toLowerCase();
|
||||||
@@ -214,7 +290,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
setOverflowMap({});
|
setOverflowMap({});
|
||||||
setMarqueeDurations({});
|
setMarqueeDurations({});
|
||||||
}, [files, recentFiles.length, visibleAgents.length]);
|
}, [visibleFiles, visibleDirectories, visibleRecentFiles.length, visibleAgents.length]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
itemRefs.current[selectedIndex]?.scrollIntoView({
|
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||||
@@ -261,7 +337,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
}
|
}
|
||||||
window.removeEventListener('resize', updateOverflow);
|
window.removeEventListener('resize', updateOverflow);
|
||||||
};
|
};
|
||||||
}, [files]);
|
}, [visibleFiles, visibleDirectories]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const labelNode = labelRefs.current[selectedIndex];
|
const labelNode = labelRefs.current[selectedIndex];
|
||||||
@@ -305,7 +381,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = visibleAgents.length + recentFiles.length + files.length;
|
const total = visibleAgents.length + visibleDirectories.length + visibleRecentFiles.length + visibleFiles.length;
|
||||||
if (total === 0) {
|
if (total === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -329,16 +405,24 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fileIndex = safeIndex - visibleAgents.length;
|
const dirIndex = safeIndex - visibleAgents.length;
|
||||||
const selectedFile = fileIndex < recentFiles.length
|
if (dirIndex < visibleDirectories.length) {
|
||||||
? recentFiles[fileIndex]
|
const dir = visibleDirectories[dirIndex];
|
||||||
: files[fileIndex - recentFiles.length];
|
if (dir) {
|
||||||
|
handleFileSelect(dir);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fileIndex = dirIndex - visibleDirectories.length;
|
||||||
|
const selectedFile = fileIndex < visibleRecentFiles.length
|
||||||
|
? visibleRecentFiles[fileIndex]
|
||||||
|
: visibleFiles[fileIndex - visibleRecentFiles.length];
|
||||||
if (selectedFile) {
|
if (selectedFile) {
|
||||||
handleFileSelect(selectedFile);
|
handleFileSelect(selectedFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}), [files, recentFiles, visibleAgents, selectedIndex, onClose, handleFileSelect, handleAgentPick]);
|
}), [visibleFiles, visibleDirectories, visibleRecentFiles, visibleAgents, selectedIndex, onClose, handleFileSelect, handleAgentPick]);
|
||||||
|
|
||||||
const getFileIcon = (file: FileInfo) => {
|
const getFileIcon = (file: FileInfo) => {
|
||||||
const ext = file.extension?.toLowerCase();
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
@@ -373,11 +463,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
{showTabs ? (
|
{showTabs ? (
|
||||||
<div className="px-2 pt-2 pb-1 border-b border-border/60">
|
<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">
|
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-elevated)] p-1">
|
||||||
{([
|
{tabs.map((tab) => (
|
||||||
{ id: 'commands' as const, label: 'Commands' },
|
|
||||||
{ id: 'agents' as const, label: 'Agents' },
|
|
||||||
{ id: 'files' as const, label: 'Files' },
|
|
||||||
]).map((tab) => (
|
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -411,7 +497,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0">
|
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0">
|
||||||
{loading ? (
|
{(!scopeResultsToActiveTab || activeTab === 'files') && loading ? (
|
||||||
<div className="flex items-center justify-center py-4">
|
<div className="flex items-center justify-center py-4">
|
||||||
<RiRefreshLine className="h-4 w-4 animate-spin text-muted-foreground" />
|
<RiRefreshLine className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
@@ -441,14 +527,41 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
})}
|
})}
|
||||||
{visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
|
{visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
|
||||||
<div className="px-3 py-1 typography-meta text-muted-foreground">
|
<div className="px-3 py-1 typography-meta text-muted-foreground">
|
||||||
Type to search more agents
|
{t('chat.fileMentionAutocomplete.searchMoreAgents')}
|
||||||
</div>
|
</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" />
|
<div className="my-1 border-t border-border/60" />
|
||||||
)}
|
)}
|
||||||
{recentFiles.map((file, index) => {
|
{visibleDirectories.map((dir, index) => {
|
||||||
const rowIndex = visibleAgents.length + 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 relativePath = file.relativePath || file.name;
|
||||||
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
|
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
|
||||||
const isSelected = selectedIndex === rowIndex;
|
const isSelected = selectedIndex === rowIndex;
|
||||||
@@ -496,11 +609,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{recentFiles.length > 0 && files.length > 0 && (
|
{visibleRecentFiles.length > 0 && visibleFiles.length > 0 && (
|
||||||
<div className="my-1 border-t border-border/60" />
|
<div className="my-1 border-t border-border/60" />
|
||||||
)}
|
)}
|
||||||
{files.map((file, index) => {
|
{visibleFiles.map((file, index) => {
|
||||||
const rowIndex = visibleAgents.length + recentFiles.length + index;
|
const rowIndex = visibleAgents.length + visibleDirectories.length + visibleRecentFiles.length + index;
|
||||||
const relativePath = file.relativePath || file.name;
|
const relativePath = file.relativePath || file.name;
|
||||||
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
|
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
|
||||||
const isSelected = selectedIndex === rowIndex;
|
const isSelected = selectedIndex === rowIndex;
|
||||||
@@ -553,16 +666,16 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
</React.Fragment>
|
</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">
|
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
||||||
No matches found
|
{t('chat.fileMentionAutocomplete.empty')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
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 { getAgentDisplayName } from './mobileControlsUtils';
|
||||||
import { getAgentColor } from '@/lib/agentColors';
|
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
|
// NOTE: Use pointer events instead of onClick to keep soft keyboard open on mobile
|
||||||
export const MobileAgentButton: React.FC<MobileAgentButtonProps> = ({ onCycleAgent, onOpenAgentPanel, className }) => {
|
export const MobileAgentButton: React.FC<MobileAgentButtonProps> = ({ onCycleAgent, onOpenAgentPanel, className }) => {
|
||||||
const { currentAgentName, getVisibleAgents } = useConfigStore();
|
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
||||||
const sessionAgentName = useSessionStore((state) =>
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
|
const sessionAgentName = useSelectionStore((state) =>
|
||||||
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
|
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ interface MobileModelButtonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const MobileModelButton: React.FC<MobileModelButtonProps> = ({ onOpenModel, className }) => {
|
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 currentProvider = getCurrentProvider();
|
||||||
const modelLabel = getModelDisplayName(currentProvider, currentModelId);
|
const modelLabel = getModelDisplayName(currentProvider, currentModelId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React from 'react';
|
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 { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
@@ -36,8 +38,6 @@ import { CSS } from '@dnd-kit/utilities';
|
|||||||
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
|
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
|
||||||
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { toast } from '@/components/ui';
|
|
||||||
import { isTauriShell, isDesktopLocalOriginActive, requestDirectoryAccess } from '@/lib/desktop';
|
|
||||||
import { sessionEvents } from '@/lib/sessionEvents';
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -52,10 +52,11 @@ import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
|
|||||||
import { useDrawerSwipe } from '@/hooks/useDrawerSwipe';
|
import { useDrawerSwipe } from '@/hooks/useDrawerSwipe';
|
||||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
|
import { useNotificationStore } from '@/sync/notification-store';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
interface MobileSessionStatusBarProps {
|
interface MobileSessionStatusBarProps {
|
||||||
onSessionSwitch?: (sessionId: string) => void;
|
onSessionSwitch?: (sessionId: string) => void;
|
||||||
cornerRadius?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionWithStatus extends Session {
|
interface SessionWithStatus extends Session {
|
||||||
@@ -74,9 +75,10 @@ const normalize = (value: string): string => {
|
|||||||
|
|
||||||
function useSessionGrouping(
|
function useSessionGrouping(
|
||||||
sessions: Session[],
|
sessions: Session[],
|
||||||
sessionStatus: Map<string, { type: string }> | undefined,
|
sessionStatus: Record<string, { type: string }> | undefined
|
||||||
sessionAttentionStates: Map<string, { needsAttention: boolean }> | undefined
|
|
||||||
) {
|
) {
|
||||||
|
const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount);
|
||||||
|
|
||||||
const parentChildMap = React.useMemo(() => {
|
const parentChildMap = React.useMemo(() => {
|
||||||
const map = new Map<string, Session[]>();
|
const map = new Map<string, Session[]>();
|
||||||
const allIds = new Set(sessions.map((s) => s.id));
|
const allIds = new Set(sessions.map((s) => s.id));
|
||||||
@@ -91,7 +93,7 @@ function useSessionGrouping(
|
|||||||
}, [sessions]);
|
}, [sessions]);
|
||||||
|
|
||||||
const getStatusType = React.useCallback((sessionId: string): 'busy' | 'retry' | 'idle' => {
|
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;
|
if (status?.type === 'busy' || status?.type === 'retry') return status.type;
|
||||||
return 'idle';
|
return 'idle';
|
||||||
}, [sessionStatus]);
|
}, [sessionStatus]);
|
||||||
@@ -115,9 +117,10 @@ function useSessionGrouping(
|
|||||||
}, [parentChildMap, getStatusType]);
|
}, [parentChildMap, getStatusType]);
|
||||||
|
|
||||||
const processedSessions = React.useMemo(() => {
|
const processedSessions = React.useMemo(() => {
|
||||||
|
const sessionIds = new Set(sessions.map((s) => s.id));
|
||||||
const topLevel = sessions.filter((session) => {
|
const topLevel = sessions.filter((session) => {
|
||||||
const parentID = (session as { parentID?: string }).parentID;
|
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[] = [];
|
const running: SessionWithStatus[] = [];
|
||||||
@@ -126,7 +129,7 @@ function useSessionGrouping(
|
|||||||
topLevel.forEach((session) => {
|
topLevel.forEach((session) => {
|
||||||
const statusType = getStatusType(session.id);
|
const statusType = getStatusType(session.id);
|
||||||
const hasRunning = hasRunningChildren(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 = {
|
const enriched: SessionWithStatus = {
|
||||||
...session,
|
...session,
|
||||||
@@ -155,28 +158,27 @@ function useSessionGrouping(
|
|||||||
viewed.sort(sortByUpdated);
|
viewed.sort(sortByUpdated);
|
||||||
|
|
||||||
return [...running, ...viewed];
|
return [...running, ...viewed];
|
||||||
}, [sessions, getStatusType, hasRunningChildren, getRunningChildrenCount, getChildIndicators, sessionAttentionStates]);
|
}, [sessions, getStatusType, hasRunningChildren, getRunningChildrenCount, getChildIndicators, unseenCounts]);
|
||||||
|
|
||||||
const totalRunning = processedSessions.reduce((sum, s) => {
|
const totalRunning = processedSessions.reduce((sum, s) => {
|
||||||
const selfRunning = s._statusType !== 'idle' ? 1 : 0;
|
const selfRunning = s._statusType !== 'idle' ? 1 : 0;
|
||||||
return sum + selfRunning + (s._runningChildrenCount ?? 0);
|
return sum + selfRunning + (s._runningChildrenCount ?? 0);
|
||||||
}, 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 };
|
return { sessions: processedSessions, totalRunning, totalUnread, totalCount: processedSessions.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
function useSessionHelpers(
|
function useSessionHelpers(
|
||||||
agents: Array<{ name: string }>,
|
agents: Array<{ name: string }>,
|
||||||
sessionStatus: Map<string, { type: string }> | undefined,
|
sessionStatus: Record<string, { type: string }> | undefined
|
||||||
sessionAttentionStates: Map<string, { needsAttention: boolean }> | undefined
|
|
||||||
) {
|
) {
|
||||||
const getSessionAgentName = React.useCallback((session: Session): string => {
|
const getSessionAgentName = React.useCallback((session: Session): string => {
|
||||||
const agent = (session as { agent?: string }).agent;
|
const agent = (session as { agent?: string }).agent;
|
||||||
if (agent) return agent;
|
if (agent) return agent;
|
||||||
|
|
||||||
const sessionAgentSelection = useSessionStore.getState().getSessionAgentSelection(session.id);
|
const sessionAgentSelection = useSelectionStore.getState().getSessionAgentSelection(session.id);
|
||||||
if (sessionAgentSelection) return sessionAgentSelection;
|
if (sessionAgentSelection) return sessionAgentSelection;
|
||||||
|
|
||||||
return agents[0]?.name ?? 'agent';
|
return agents[0]?.name ?? 'agent';
|
||||||
@@ -189,14 +191,14 @@ function useSessionHelpers(
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isRunning = React.useCallback((sessionId: string): boolean => {
|
const isRunning = React.useCallback((sessionId: string): boolean => {
|
||||||
const status = sessionStatus?.get(sessionId);
|
const status = sessionStatus?.[sessionId];
|
||||||
return status?.type === 'busy' || status?.type === 'retry';
|
return status?.type === 'busy' || status?.type === 'retry';
|
||||||
}, [sessionStatus]);
|
}, [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 => {
|
const needsAttention = React.useCallback((sessionId: string): boolean => {
|
||||||
return sessionAttentionStates?.get(sessionId)?.needsAttention ?? false;
|
return (unseenCounts[sessionId] ?? 0) > 0;
|
||||||
}, [sessionAttentionStates]);
|
}, [unseenCounts]);
|
||||||
|
|
||||||
return { getSessionAgentName, getSessionTitle, isRunning, needsAttention };
|
return { getSessionAgentName, getSessionTitle, isRunning, needsAttention };
|
||||||
}
|
}
|
||||||
@@ -204,17 +206,16 @@ function useSessionHelpers(
|
|||||||
// Hook to calculate project status indicators
|
// Hook to calculate project status indicators
|
||||||
function useProjectStatus(
|
function useProjectStatus(
|
||||||
sessions: Session[],
|
sessions: Session[],
|
||||||
sessionStatus: Map<string, { type: string }> | undefined,
|
sessionStatus: Record<string, { type: string }> | undefined,
|
||||||
sessionAttentionStates: Map<string, { needsAttention: boolean }> | undefined,
|
|
||||||
currentSessionId: string | null
|
currentSessionId: string | null
|
||||||
) {
|
) {
|
||||||
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
|
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||||
const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory);
|
const getSessionsByDirectory = useSessionUIStore((state) => state.getSessionsByDirectory);
|
||||||
const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory);
|
const notifUnseenCounts = useNotificationStore((s) => s.index.session.unseenCount);
|
||||||
|
|
||||||
const projectStatusMap = React.useCallback((projectPath: string): { hasRunning: boolean; hasUnread: boolean } => {
|
const projectStatusMap = React.useCallback((projectPath: string): { hasRunning: boolean; hasUnread: boolean } => {
|
||||||
const getStatusType = (sessionId: string): 'busy' | 'retry' | 'idle' => {
|
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;
|
if (status?.type === 'busy' || status?.type === 'retry') return status.type;
|
||||||
return 'idle';
|
return 'idle';
|
||||||
};
|
};
|
||||||
@@ -241,7 +242,7 @@ function useProjectStatus(
|
|||||||
let hasUnread = false;
|
let hasUnread = false;
|
||||||
|
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
const list = sessionsByDirectory.get(dir) ?? getSessionsByDirectory(dir);
|
const list = getSessionsByDirectory(dir);
|
||||||
for (const session of list) {
|
for (const session of list) {
|
||||||
if (!session?.id || seen.has(session.id)) {
|
if (!session?.id || seen.has(session.id)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -253,7 +254,7 @@ function useProjectStatus(
|
|||||||
hasRunning = true;
|
hasRunning = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.id !== currentSessionId && sessionAttentionStates?.get(session.id)?.needsAttention === true) {
|
if (session.id !== currentSessionId && (notifUnseenCounts[session.id] ?? 0) > 0) {
|
||||||
hasUnread = true;
|
hasUnread = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +268,7 @@ function useProjectStatus(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { hasRunning, hasUnread };
|
return { hasRunning, hasUnread };
|
||||||
}, [sessionsByDirectory, getSessionsByDirectory, availableWorktreesByProject, sessionStatus, sessionAttentionStates, currentSessionId]);
|
}, [getSessionsByDirectory, availableWorktreesByProject, sessionStatus, notifUnseenCounts, currentSessionId]);
|
||||||
|
|
||||||
return projectStatusMap;
|
return projectStatusMap;
|
||||||
}
|
}
|
||||||
@@ -744,6 +745,7 @@ function ProjectEditPanel({
|
|||||||
onDelete,
|
onDelete,
|
||||||
homeDirectory,
|
homeDirectory,
|
||||||
}: ProjectEditPanelProps) {
|
}: ProjectEditPanelProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [localProjects, setLocalProjects] = React.useState(projects);
|
const [localProjects, setLocalProjects] = React.useState(projects);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -796,10 +798,10 @@ function ProjectEditPanel({
|
|||||||
<MobileOverlayPanel
|
<MobileOverlayPanel
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title="Edit Projects"
|
title={t('chat.mobileStatus.editProjects.title')}
|
||||||
footer={
|
footer={
|
||||||
<p className="text-xs text-[var(--surface-mutedForeground)] text-center">
|
<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>
|
</p>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -831,7 +833,7 @@ function ProjectEditPanel({
|
|||||||
|
|
||||||
{localProjects.length === 0 && (
|
{localProjects.length === 0 && (
|
||||||
<div className="text-center py-8 text-[var(--surface-mutedForeground)]">
|
<div className="text-center py-8 text-[var(--surface-mutedForeground)]">
|
||||||
No projects to edit
|
{t('chat.mobileStatus.editProjects.empty')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -957,6 +959,7 @@ function ProjectBar({
|
|||||||
onRemoveProject,
|
onRemoveProject,
|
||||||
homeDirectory
|
homeDirectory
|
||||||
}: ProjectBarProps) {
|
}: ProjectBarProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||||
const [editPanelOpen, setEditPanelOpen] = React.useState(false);
|
const [editPanelOpen, setEditPanelOpen] = React.useState(false);
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
|
||||||
@@ -1011,12 +1014,12 @@ function ProjectBar({
|
|||||||
if (projects.length === 0) {
|
if (projects.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 px-2 py-1 border-b border-[var(--interactive-border)] bg-transparent">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onAddProject}
|
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"
|
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" />
|
<RiAddLine className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1092,7 +1095,7 @@ function ProjectBar({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onAddProject}
|
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"
|
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" />
|
<RiAddLine className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1101,17 +1104,17 @@ function ProjectBar({
|
|||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<DialogContent className="max-w-sm">
|
<DialogContent className="max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Remove Project</DialogTitle>
|
<DialogTitle>{t('chat.mobileStatus.projects.removeTitle')}</DialogTitle>
|
||||||
<DialogDescription>
|
<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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter className="flex gap-2">
|
<DialogFooter className="flex gap-2">
|
||||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||||
Cancel
|
{t('chat.mobileStatus.projects.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={handleConfirmDelete}>
|
<Button variant="destructive" onClick={handleConfirmDelete}>
|
||||||
Remove
|
{t('chat.mobileStatus.projects.remove')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -1159,7 +1162,6 @@ function CollapsedView({
|
|||||||
currentProjectColor,
|
currentProjectColor,
|
||||||
onToggle,
|
onToggle,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
cornerRadius,
|
|
||||||
contextUsage,
|
contextUsage,
|
||||||
childIndicators = [],
|
childIndicators = [],
|
||||||
}: {
|
}: {
|
||||||
@@ -1173,18 +1175,18 @@ function CollapsedView({
|
|||||||
currentProjectColor?: string | null;
|
currentProjectColor?: string | null;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onNewSession: () => void;
|
onNewSession: () => void;
|
||||||
cornerRadius?: number;
|
|
||||||
contextUsage: SessionContextUsage | null;
|
contextUsage: SessionContextUsage | null;
|
||||||
childIndicators?: Array<{ session: Session; isRunning: boolean }>;
|
childIndicators?: Array<{ session: Session; isRunning: boolean }>;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe();
|
const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
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={{
|
style={{
|
||||||
borderTopLeftRadius: cornerRadius,
|
borderTopLeftRadius: 'var(--radius-lg)',
|
||||||
borderTopRightRadius: cornerRadius,
|
borderTopRightRadius: 'var(--radius-lg)',
|
||||||
}}
|
}}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchMove={handleTouchMove}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1249,7 +1251,6 @@ function ExpandedView({
|
|||||||
getSessionAgentName,
|
getSessionAgentName,
|
||||||
getSessionTitle,
|
getSessionTitle,
|
||||||
needsAttention,
|
needsAttention,
|
||||||
cornerRadius,
|
|
||||||
contextUsage,
|
contextUsage,
|
||||||
projects,
|
projects,
|
||||||
activeProjectId,
|
activeProjectId,
|
||||||
@@ -1278,7 +1279,6 @@ function ExpandedView({
|
|||||||
getSessionAgentName: (s: Session) => string;
|
getSessionAgentName: (s: Session) => string;
|
||||||
getSessionTitle: (s: Session) => string;
|
getSessionTitle: (s: Session) => string;
|
||||||
needsAttention: (sessionId: string) => boolean;
|
needsAttention: (sessionId: string) => boolean;
|
||||||
cornerRadius?: number;
|
|
||||||
contextUsage: SessionContextUsage | null;
|
contextUsage: SessionContextUsage | null;
|
||||||
projects: ProjectEntry[];
|
projects: ProjectEntry[];
|
||||||
activeProjectId: string | null;
|
activeProjectId: string | null;
|
||||||
@@ -1286,11 +1286,12 @@ function ExpandedView({
|
|||||||
homeDirectory: string | null;
|
homeDirectory: string | null;
|
||||||
childIndicators?: Array<{ session: Session; isRunning: boolean }>;
|
childIndicators?: Array<{ session: Session; isRunning: boolean }>;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
const [collapsedHeight, setCollapsedHeight] = React.useState<number | null>(null);
|
const [collapsedHeight, setCollapsedHeight] = React.useState<number | null>(null);
|
||||||
const [hasMeasured, setHasMeasured] = React.useState(false);
|
const [hasMeasured, setHasMeasured] = React.useState(false);
|
||||||
const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe();
|
const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe();
|
||||||
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
|
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (containerRef.current && !hasMeasured && !isExpanded) {
|
if (containerRef.current && !hasMeasured && !isExpanded) {
|
||||||
@@ -1334,8 +1335,8 @@ function ExpandedView({
|
|||||||
<div
|
<div
|
||||||
className="w-full border-b border-[var(--interactive-border)] bg-[var(--surface-muted)] order-first overflow-hidden"
|
className="w-full border-b border-[var(--interactive-border)] bg-[var(--surface-muted)] order-first overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
borderTopLeftRadius: cornerRadius,
|
borderTopLeftRadius: 'var(--radius-lg)',
|
||||||
borderTopRightRadius: cornerRadius,
|
borderTopRightRadius: 'var(--radius-lg)',
|
||||||
}}
|
}}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchMove={handleTouchMove}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1403,7 +1404,7 @@ function ExpandedView({
|
|||||||
>
|
>
|
||||||
{displaySessions.length === 0 ? (
|
{displaySessions.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-3 text-[11px] text-[var(--surface-mutedForeground)]">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
displaySessions.map((session) => (
|
displaySessions.map((session) => (
|
||||||
@@ -1426,40 +1427,41 @@ function ExpandedView({
|
|||||||
|
|
||||||
export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
||||||
onSessionSwitch,
|
onSessionSwitch,
|
||||||
cornerRadius,
|
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const { currentTheme } = useThemeSystem();
|
const { currentTheme } = useThemeSystem();
|
||||||
const sessions = useSessionStore((state) => state.sessions);
|
const sessions = useSessions();
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const sessionStatus = useSessionStore((state) => state.sessionStatus);
|
const sessionStatus = useAllSessionStatuses();
|
||||||
const sessionAttentionStates = useSessionStore((state) => state.sessionAttentionStates);
|
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||||
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||||
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
|
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
|
||||||
const getContextUsage = useSessionStore((state) => state.getContextUsage);
|
|
||||||
const agents = useConfigStore((state) => state.agents);
|
const agents = useConfigStore((state) => state.agents);
|
||||||
const { getCurrentModel } = useConfigStore();
|
const getCurrentModel = useConfigStore((state) => state.getCurrentModel);
|
||||||
const { isMobile, showMobileSessionStatusBar, isMobileSessionStatusBarCollapsed, setIsMobileSessionStatusBarCollapsed } = useUIStore();
|
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);
|
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||||
|
|
||||||
// Project store
|
// Project store
|
||||||
const projects = useProjectsStore((state) => state.projects);
|
const projects = useProjectsStore((state) => state.projects);
|
||||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||||
const setActiveProject = useProjectsStore((state) => state.setActiveProject);
|
const setActiveProject = useProjectsStore((state) => state.setActiveProject);
|
||||||
const addProject = useProjectsStore((state) => state.addProject);
|
|
||||||
const removeProject = useProjectsStore((state) => state.removeProject);
|
const removeProject = useProjectsStore((state) => state.removeProject);
|
||||||
const getActiveProject = useProjectsStore((state) => state.getActiveProject);
|
const getActiveProject = useProjectsStore((state) => state.getActiveProject);
|
||||||
|
|
||||||
// Directory store
|
// Directory store
|
||||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||||
|
|
||||||
const { sessions: sortedSessions, totalRunning, totalUnread, totalCount } = useSessionGrouping(sessions, sessionStatus, sessionAttentionStates);
|
const { sessions: sortedSessions, totalRunning, totalUnread, totalCount } = useSessionGrouping(sessions, sessionStatus);
|
||||||
const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents, sessionStatus, sessionAttentionStates);
|
const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents, sessionStatus);
|
||||||
const getProjectStatus = useProjectStatus(sessions, sessionStatus, sessionAttentionStates, currentSessionId);
|
const getProjectStatus = useProjectStatus(sessions, sessionStatus, currentSessionId);
|
||||||
|
|
||||||
const currentSession = sessions.find((s) => s.id === currentSessionId);
|
const currentSession = sessions.find((s) => s.id === currentSessionId);
|
||||||
const currentSessionTitle = currentSession
|
const currentSessionTitle = currentSession
|
||||||
? getSessionTitle(currentSession)
|
? getSessionTitle(currentSession)
|
||||||
: '← Swipe here to open sidebars →';
|
: t('chat.mobileStatus.swipeHint');
|
||||||
|
|
||||||
// Calculate current session's child indicators
|
// Calculate current session's child indicators
|
||||||
const currentSessionWithStatus = sortedSessions.find((s) => s.id === currentSessionId);
|
const currentSessionWithStatus = sortedSessions.find((s) => s.id === currentSessionId);
|
||||||
@@ -1487,7 +1489,6 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
|||||||
const contextUsage = getContextUsage(contextLimit, outputLimit);
|
const contextUsage = getContextUsage(contextLimit, outputLimit);
|
||||||
|
|
||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
|
|
||||||
|
|
||||||
if (!isMobile || !showMobileSessionStatusBar || totalCount === 0) {
|
if (!isMobile || !showMobileSessionStatusBar || totalCount === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1515,29 +1516,7 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddProject = () => {
|
const handleAddProject = () => {
|
||||||
if (!tauriIpcAvailable || !isDesktopLocalOriginActive()) {
|
sessionEvents.requestDirectoryDialog();
|
||||||
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');
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isMobileSessionStatusBarCollapsed) {
|
if (isMobileSessionStatusBarCollapsed) {
|
||||||
@@ -1553,7 +1532,6 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
|||||||
currentProjectColor={currentProjectColor}
|
currentProjectColor={currentProjectColor}
|
||||||
onToggle={() => setIsMobileSessionStatusBarCollapsed(false)}
|
onToggle={() => setIsMobileSessionStatusBarCollapsed(false)}
|
||||||
onNewSession={handleCreateSession}
|
onNewSession={handleCreateSession}
|
||||||
cornerRadius={cornerRadius}
|
|
||||||
contextUsage={contextUsage}
|
contextUsage={contextUsage}
|
||||||
childIndicators={currentSessionChildIndicators}
|
childIndicators={currentSessionChildIndicators}
|
||||||
/>
|
/>
|
||||||
@@ -1586,7 +1564,6 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
|||||||
getSessionAgentName={getSessionAgentName}
|
getSessionAgentName={getSessionAgentName}
|
||||||
getSessionTitle={getSessionTitle}
|
getSessionTitle={getSessionTitle}
|
||||||
needsAttention={needsAttention}
|
needsAttention={needsAttention}
|
||||||
cornerRadius={cornerRadius}
|
|
||||||
contextUsage={contextUsage}
|
contextUsage={contextUsage}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
activeProjectId={activeProjectId}
|
activeProjectId={activeProjectId}
|
||||||
|
|||||||
@@ -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 { RiCheckLine, RiCloseLine, RiFileEditLine, RiGlobalLine, RiPencilAiLine, RiQuestionLine, RiTerminalBoxLine, RiTimeLine, RiToolsLine } from '@remixicon/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { PermissionRequest, PermissionResponse } from '@/types/permission';
|
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 { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import { DiffPreview, WritePreview } from './DiffPreview';
|
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 {
|
interface PermissionCardProps {
|
||||||
permission: PermissionRequest;
|
permission: PermissionRequest;
|
||||||
@@ -60,17 +93,17 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
permission,
|
permission,
|
||||||
onResponse
|
onResponse
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const [isResponding, setIsResponding] = React.useState(false);
|
const [isResponding, setIsResponding] = React.useState(false);
|
||||||
const [hasResponded, setHasResponded] = React.useState(false);
|
const [hasResponded, setHasResponded] = React.useState(false);
|
||||||
const { respondToPermission } = useSessionStore();
|
const respondToPermission = sessionActions.respondToPermission;
|
||||||
const isFromSubagent = useSessionStore(
|
const sessions = useSessions();
|
||||||
React.useCallback((state) => {
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const currentSessionId = state.currentSessionId;
|
const isFromSubagent = React.useMemo(() => {
|
||||||
if (!currentSessionId || permission.sessionID === currentSessionId) return false;
|
if (!currentSessionId || permission.sessionID === currentSessionId) return false;
|
||||||
const sourceSession = state.sessions.find((session) => session.id === permission.sessionID);
|
const sourceSession = sessions.find((session) => session.id === permission.sessionID);
|
||||||
return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId);
|
return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId);
|
||||||
}, [permission.sessionID])
|
}, [permission.sessionID, currentSessionId, sessions]);
|
||||||
);
|
|
||||||
const { currentTheme } = useThemeSystem();
|
const { currentTheme } = useThemeSystem();
|
||||||
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
||||||
|
|
||||||
@@ -81,7 +114,9 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
await respondToPermission(permission.sessionID, permission.id, response);
|
await respondToPermission(permission.sessionID, permission.id, response);
|
||||||
setHasResponded(true);
|
setHasResponded(true);
|
||||||
onResponse?.(response);
|
onResponse?.(response);
|
||||||
} catch { /* ignored */ } finally {
|
} catch (error) {
|
||||||
|
console.error('[PermissionCard] Failed to respond to permission:', error);
|
||||||
|
} finally {
|
||||||
setIsResponding(false);
|
setIsResponding(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -122,12 +157,12 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
{workingDir && (
|
{workingDir && (
|
||||||
<div className="typography-meta text-muted-foreground mb-2">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{timeout && (
|
{timeout && (
|
||||||
<div className="typography-meta text-muted-foreground mb-2">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{}
|
{}
|
||||||
@@ -137,25 +172,8 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
language="bash"
|
language="bash"
|
||||||
style={syntaxTheme}
|
style={syntaxTheme}
|
||||||
PreTag="div"
|
PreTag="div"
|
||||||
customStyle={{
|
customStyle={PERMISSION_BASH_CUSTOM_STYLE}
|
||||||
margin: 0,
|
codeTagProps={PERMISSION_BASH_CODE_TAG_PROPS}
|
||||||
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'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
wrapLongLines={true}
|
wrapLongLines={true}
|
||||||
>
|
>
|
||||||
{command}
|
{command}
|
||||||
@@ -214,7 +232,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
<>
|
<>
|
||||||
{url && (
|
{url && (
|
||||||
<div className="mb-2">
|
<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">
|
<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">
|
<span className="typography-meta font-semibold px-1.5 py-0.5 bg-primary/20 text-primary rounded">
|
||||||
{method}
|
{method}
|
||||||
@@ -227,19 +245,12 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
{headers && Object.keys(headers).length > 0 && (
|
{headers && Object.keys(headers).length > 0 && (
|
||||||
<div className="mb-2">
|
<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">
|
<ScrollableOverlay outerClassName="max-h-24" className="p-0">
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
language="json"
|
language="json"
|
||||||
style={syntaxTheme}
|
style={syntaxTheme}
|
||||||
customStyle={{
|
customStyle={PERMISSION_JSON_CUSTOM_STYLE}
|
||||||
margin: 0,
|
|
||||||
padding: '0.5rem',
|
|
||||||
fontSize: 'var(--text-meta)',
|
|
||||||
lineHeight: '1.25rem',
|
|
||||||
background: 'rgb(var(--muted) / 0.3)',
|
|
||||||
borderRadius: '0.25rem'
|
|
||||||
}}
|
|
||||||
wrapLongLines={true}
|
wrapLongLines={true}
|
||||||
>
|
>
|
||||||
{JSON.stringify(headers, null, 2)}
|
{JSON.stringify(headers, null, 2)}
|
||||||
@@ -249,19 +260,12 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
{body && (
|
{body && (
|
||||||
<div className="mb-2">
|
<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">
|
<ScrollableOverlay outerClassName="max-h-32" className="p-0">
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
language={typeof body === 'object' ? 'json' : 'text'}
|
language={typeof body === 'object' ? 'json' : 'text'}
|
||||||
style={syntaxTheme}
|
style={syntaxTheme}
|
||||||
customStyle={{
|
customStyle={PERMISSION_JSON_CUSTOM_STYLE}
|
||||||
margin: 0,
|
|
||||||
padding: '0.5rem',
|
|
||||||
fontSize: 'var(--text-meta)',
|
|
||||||
lineHeight: '1.25rem',
|
|
||||||
background: 'rgb(var(--muted) / 0.3)',
|
|
||||||
borderRadius: '0.25rem'
|
|
||||||
}}
|
|
||||||
wrapLongLines={true}
|
wrapLongLines={true}
|
||||||
>
|
>
|
||||||
{typeof body === 'object' ? JSON.stringify(body, null, 2) : String(body)}
|
{typeof body === 'object' ? JSON.stringify(body, null, 2) : String(body)}
|
||||||
@@ -290,7 +294,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
{genericContent && (
|
{genericContent && (
|
||||||
<div className="mb-2">
|
<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">
|
<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">
|
<pre className="typography-meta font-mono px-2 py-1 bg-muted/30 rounded whitespace-pre-wrap break-all">
|
||||||
{String(genericContent)}
|
{String(genericContent)}
|
||||||
@@ -301,7 +305,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
{}
|
{}
|
||||||
{Object.keys(permission.metadata).length > 0 && !genericContent && !description && (
|
{Object.keys(permission.metadata).length > 0 && !genericContent && !description && (
|
||||||
<div>
|
<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">
|
<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">
|
<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)}
|
{JSON.stringify(permission.metadata, null, 2)}
|
||||||
@@ -342,7 +346,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
<div className="px-2 py-2">
|
<div className="px-2 py-2">
|
||||||
{permission.patterns.length > 0 && (
|
{permission.patterns.length > 0 && (
|
||||||
<div className="mb-2">
|
<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">
|
<code className="typography-meta px-2 py-1 bg-muted/30 rounded block break-all">
|
||||||
{permission.patterns.join(", ")}
|
{permission.patterns.join(", ")}
|
||||||
</code>
|
</code>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import React from 'react';
|
|||||||
import { RiCheckLine, RiCloseLine, RiTimeLine } from '@remixicon/react';
|
import { RiCheckLine, RiCloseLine, RiTimeLine } from '@remixicon/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { PermissionRequest as PermissionRequestPayload, PermissionResponse } from '@/types/permission';
|
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 {
|
interface PermissionRequestProps {
|
||||||
permission: PermissionRequestPayload;
|
permission: PermissionRequestPayload;
|
||||||
@@ -13,9 +14,10 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
|
|||||||
permission,
|
permission,
|
||||||
onResponse
|
onResponse
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const [isResponding, setIsResponding] = React.useState(false);
|
const [isResponding, setIsResponding] = React.useState(false);
|
||||||
const [hasResponded, setHasResponded] = React.useState(false);
|
const [hasResponded, setHasResponded] = React.useState(false);
|
||||||
const { respondToPermission } = useSessionStore();
|
const respondToPermission = sessionActions.respondToPermission;
|
||||||
|
|
||||||
const handleResponse = async (response: PermissionResponse) => {
|
const handleResponse = async (response: PermissionResponse) => {
|
||||||
setIsResponding(true);
|
setIsResponding(true);
|
||||||
@@ -24,7 +26,9 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
|
|||||||
await respondToPermission(permission.sessionID, permission.id, response);
|
await respondToPermission(permission.sessionID, permission.id, response);
|
||||||
setHasResponded(true);
|
setHasResponded(true);
|
||||||
onResponse?.(response);
|
onResponse?.(response);
|
||||||
} catch { /* ignored */ } finally {
|
} catch (error) {
|
||||||
|
console.error('[PermissionRequest] Failed to respond to permission:', error);
|
||||||
|
} finally {
|
||||||
setIsResponding(false);
|
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="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<span className="typography-ui-label font-medium text-muted-foreground">
|
<span className="typography-ui-label font-medium text-muted-foreground">
|
||||||
Permission required:
|
{t('chat.permissionRequest.required')}
|
||||||
</span>
|
</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">
|
<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}
|
{command}
|
||||||
@@ -70,7 +74,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RiCheckLine className="h-3 w-3" />
|
<RiCheckLine className="h-3 w-3" />
|
||||||
Once
|
{t('chat.permissionRequest.actions.once')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -92,7 +96,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RiTimeLine className="h-3 w-3" />
|
<RiTimeLine className="h-3 w-3" />
|
||||||
Always
|
{t('chat.permissionRequest.actions.always')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -114,7 +118,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RiCloseLine className="h-3 w-3" />
|
<RiCloseLine className="h-3 w-3" />
|
||||||
Reject
|
{t('chat.permissionRequest.actions.reject')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isResponding && (
|
{isResponding && (
|
||||||
@@ -125,4 +129,4 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
interface PermissionToastActionsProps {
|
interface PermissionToastActionsProps {
|
||||||
sessionTitle: string;
|
sessionTitle: string;
|
||||||
@@ -27,10 +28,11 @@ export const PermissionToastActions: React.FC<PermissionToastActionsProps> = ({
|
|||||||
onAlways,
|
onAlways,
|
||||||
onDeny,
|
onDeny,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const [isBusy, setIsBusy] = React.useState(false);
|
const [isBusy, setIsBusy] = React.useState(false);
|
||||||
const actionContext = sessionTitle.trim().length > 0 ? ` for ${sessionTitle}` : '';
|
const hasSessionTitle = sessionTitle.trim().length > 0;
|
||||||
const sessionPreview = truncateToastText(sessionTitle, 64) || 'Session';
|
const sessionPreview = truncateToastText(sessionTitle, 64) || t('chat.permissionToast.sessionFallback');
|
||||||
const permissionPreview = truncateToastText(permissionBody, 120) || 'Permission details unavailable';
|
const permissionPreview = truncateToastText(permissionBody, 120) || t('chat.permissionToast.permissionFallback');
|
||||||
|
|
||||||
const handleAction = async (action: () => Promise<void> | void) => {
|
const handleAction = async (action: () => Promise<void> | void) => {
|
||||||
if (isBusy || disabled) return;
|
if (isBusy || disabled) return;
|
||||||
@@ -46,13 +48,13 @@ export const PermissionToastActions: React.FC<PermissionToastActionsProps> = ({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="mb-1.5 min-w-0 space-y-0.5">
|
<div className="mb-1.5 min-w-0 space-y-0.5">
|
||||||
<p className="typography-meta text-muted-foreground" title={sessionTitle}>
|
<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">
|
<span className="inline-block max-w-[280px] align-bottom truncate text-foreground">
|
||||||
{sessionPreview}
|
{sessionPreview}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="typography-meta text-muted-foreground" title={permissionBody}>
|
<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">
|
<span className="inline-block max-w-[280px] align-bottom truncate">
|
||||||
{permissionPreview}
|
{permissionPreview}
|
||||||
</span>
|
</span>
|
||||||
@@ -63,7 +65,9 @@ export const PermissionToastActions: React.FC<PermissionToastActionsProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleAction(onOnce)}
|
onClick={() => handleAction(onOnce)}
|
||||||
disabled={disabled || isBusy}
|
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(
|
className={cn(
|
||||||
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
|
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
|
||||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
"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)';
|
e.currentTarget.style.backgroundColor = 'rgb(var(--status-success) / 0.1)';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Once
|
{t('chat.permissionToast.actions.once')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAction(onAlways)}
|
onClick={() => handleAction(onAlways)}
|
||||||
disabled={disabled || isBusy}
|
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(
|
className={cn(
|
||||||
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
|
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
|
||||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
"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)';
|
e.currentTarget.style.backgroundColor = 'rgb(var(--muted) / 0.5)';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Always
|
{t('chat.permissionToast.actions.always')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAction(onDeny)}
|
onClick={() => handleAction(onDeny)}
|
||||||
disabled={disabled || isBusy}
|
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(
|
className={cn(
|
||||||
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
|
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
|
||||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
"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)';
|
e.currentTarget.style.backgroundColor = 'rgb(var(--status-error) / 0.1)';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Deny
|
{t('chat.permissionToast.actions.deny')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RiArrowRightSLine, RiCheckLine, RiCloseLine, RiEditLine, RiListCheck3, RiQuestionLine } from '@remixicon/react';
|
import { RiArrowRightSLine, RiCheckLine, RiCloseLine, RiEditLine, RiListCheck3, RiQuestionLine } from '@remixicon/react';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Radio } from '@/components/ui/radio';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { QuestionRequest } from '@/types/question';
|
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 {
|
interface QuestionCardProps {
|
||||||
question: QuestionRequest;
|
question: QuestionRequest;
|
||||||
@@ -14,15 +18,16 @@ type TabKey = string;
|
|||||||
const SUMMARY_TAB = 'summary';
|
const SUMMARY_TAB = 'summary';
|
||||||
|
|
||||||
export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
|
export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
|
||||||
const { respondToQuestion, rejectQuestion } = useSessionStore();
|
const { t } = useI18n();
|
||||||
const isFromSubagent = useSessionStore(
|
const respondToQuestion = sessionActions.respondToQuestion;
|
||||||
React.useCallback((state) => {
|
const rejectQuestion = sessionActions.rejectQuestion;;
|
||||||
const currentSessionId = state.currentSessionId;
|
const sessions = useSessions();
|
||||||
if (!currentSessionId || question.sessionID === currentSessionId) return false;
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const sourceSession = state.sessions.find((session) => session.id === question.sessionID);
|
const isFromSubagent = React.useMemo(() => {
|
||||||
return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId);
|
if (!currentSessionId || question.sessionID === currentSessionId) return false;
|
||||||
}, [question.sessionID])
|
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 [activeTab, setActiveTab] = React.useState<TabKey>('0');
|
||||||
const [isResponding, setIsResponding] = React.useState(false);
|
const [isResponding, setIsResponding] = React.useState(false);
|
||||||
const [hasResponded, setHasResponded] = 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
|
// Add summary tab when multiple questions
|
||||||
if (questions.length > 1) {
|
if (questions.length > 1) {
|
||||||
questionTabs.push({ value: SUMMARY_TAB, label: 'Summary' });
|
questionTabs.push({ value: SUMMARY_TAB, label: t('chat.questionCard.summaryTab') });
|
||||||
}
|
}
|
||||||
return questionTabs;
|
return questionTabs;
|
||||||
}, [questions]);
|
}, [questions, t]);
|
||||||
|
|
||||||
// Helper to get answer display for a question index
|
// Helper to get answer display for a question index
|
||||||
const getAnswerDisplay = React.useCallback((index: number): string => {
|
const getAnswerDisplay = React.useCallback((index: number): string => {
|
||||||
const isCustom = Boolean(customMode[index]);
|
const isCustom = Boolean(customMode[index]);
|
||||||
if (isCustom) {
|
if (isCustom) {
|
||||||
const value = (customText[index] ?? '').trim();
|
const value = (customText[index] ?? '').trim();
|
||||||
return value || '(no answer)';
|
return value || t('chat.questionCard.noAnswer');
|
||||||
}
|
}
|
||||||
const answers = selectedOptions[index] ?? [];
|
const answers = selectedOptions[index] ?? [];
|
||||||
return answers.length > 0 ? answers.join(', ') : '(no answer)';
|
return answers.length > 0 ? answers.join(', ') : t('chat.questionCard.noAnswer');
|
||||||
}, [customMode, customText, selectedOptions]);
|
}, [customMode, customText, selectedOptions, t]);
|
||||||
|
|
||||||
const isMultiple = Boolean(activeQuestion?.multiple);
|
const isMultiple = Boolean(activeQuestion?.multiple);
|
||||||
const selectedForActive = selectedOptions[activeIndex] ?? [];
|
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="px-2 py-1.5 border-b border-border/20">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<RiQuestionLine className="h-3.5 w-3.5 text-primary" />
|
<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 ? (
|
{isFromSubagent ? (
|
||||||
<span className="typography-micro text-muted-foreground px-1.5 py-0.5 rounded bg-foreground/5">
|
<span className="typography-micro text-muted-foreground px-1.5 py-0.5 rounded bg-foreground/5">
|
||||||
From subagent
|
{t('chat.questionCard.fromSubagent')}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{activeHeader ? (
|
{activeHeader ? (
|
||||||
@@ -246,7 +251,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{questions.map((q, index) => {
|
{questions.map((q, index) => {
|
||||||
const answer = getAnswerDisplay(index);
|
const answer = getAnswerDisplay(index);
|
||||||
const hasAnswer = answer !== '(no answer)';
|
const hasAnswer = answer !== t('chat.questionCard.noAnswer');
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
@@ -254,7 +259,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
|
|||||||
onClick={() => setActiveTab(String(index))}
|
onClick={() => setActiveTab(String(index))}
|
||||||
className="w-full text-left rounded px-1.5 py-1 hover:bg-interactive-hover/20 transition-colors"
|
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(
|
<div className={cn(
|
||||||
'typography-meta',
|
'typography-meta',
|
||||||
hasAnswer ? 'text-foreground' : 'text-muted-foreground/50 italic'
|
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>
|
<div className="typography-meta font-medium text-foreground mb-1.5">{activeQuestion.question}</div>
|
||||||
|
|
||||||
{isMultiple ? (
|
{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}
|
) : null}
|
||||||
|
|
||||||
<div className="space-y-0.5">
|
<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="flex items-start gap-2">
|
||||||
<div className="mt-0.5 shrink-0">
|
<div className="mt-0.5 shrink-0">
|
||||||
<Checkbox
|
{isMultiple ? (
|
||||||
checked={selected}
|
<Checkbox
|
||||||
onChange={() => handleToggleOption(option.label)}
|
checked={selected}
|
||||||
disabled={isResponding}
|
onChange={() => handleToggleOption(option.label)}
|
||||||
/>
|
disabled={isResponding}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Radio
|
||||||
|
checked={selected}
|
||||||
|
onChange={() => handleToggleOption(option.label)}
|
||||||
|
disabled={isResponding}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -309,7 +322,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
|
|||||||
{option.label}
|
{option.label}
|
||||||
</span>
|
</span>
|
||||||
{recommended ? (
|
{recommended ? (
|
||||||
<span className="typography-micro text-primary/80">recommended</span>
|
<span className="typography-micro text-primary/80">{t('chat.questionCard.recommended')}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{option.description ? (
|
{option.description ? (
|
||||||
@@ -342,7 +355,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
|
|||||||
'typography-meta',
|
'typography-meta',
|
||||||
isCustomActive ? 'text-foreground font-medium' : 'text-muted-foreground'
|
isCustomActive ? 'text-foreground font-medium' : 'text-muted-foreground'
|
||||||
)}>
|
)}>
|
||||||
Other…
|
{t('chat.questionCard.other')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -369,7 +382,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
|
|||||||
el.style.height = `${Math.min(Math.max(el.scrollHeight, minHeight), maxHeight)}px`;
|
el.style.height = `${Math.min(Math.max(el.scrollHeight, minHeight), maxHeight)}px`;
|
||||||
setCustomText((prev) => ({ ...prev, [activeIndex]: el.value }));
|
setCustomText((prev) => ({ ...prev, [activeIndex]: el.value }));
|
||||||
}}
|
}}
|
||||||
placeholder="Your answer"
|
placeholder={t('chat.questionCard.yourAnswer')}
|
||||||
disabled={isResponding}
|
disabled={isResponding}
|
||||||
rows={2}
|
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"
|
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 ? <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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -409,7 +422,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<RiCloseLine className="h-3 w-3" />
|
<RiCloseLine className="h-3 w-3" />
|
||||||
Dismiss
|
{t('chat.questionCard.dismiss')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isResponding ? (
|
{isResponding ? (
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
import { RiCloseLine, RiMessage2Line } from '@remixicon/react';
|
import { RiCloseLine, RiMessage2Line } from '@remixicon/react';
|
||||||
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useFileStore } from '@/stores/fileStore';
|
import { useInputStore } from '@/sync/input-store';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
interface QueuedMessageChipProps {
|
interface QueuedMessageChipProps {
|
||||||
message: QueuedMessage;
|
message: QueuedMessage;
|
||||||
@@ -11,6 +12,7 @@ interface QueuedMessageChipProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChipProps) => {
|
const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChipProps) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
|
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
|
||||||
|
|
||||||
// Get first line of message, truncated
|
// 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">
|
<span className="text-muted-foreground flex-shrink-0">
|
||||||
Queued
|
Queued
|
||||||
{attachmentCount > 0 && (
|
{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>
|
||||||
<span className="text-foreground truncate">
|
<span className="text-foreground truncate">
|
||||||
{firstLine || '(empty)'}
|
{firstLine || t('chat.queuedMessage.empty')}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -50,7 +52,7 @@ const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChi
|
|||||||
removeFromQueue(sessionId, message.id);
|
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"
|
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" />
|
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
|
||||||
</span>
|
</span>
|
||||||
@@ -67,7 +69,7 @@ interface QueuedMessageChipsProps {
|
|||||||
const EMPTY_QUEUE: QueuedMessage[] = [];
|
const EMPTY_QUEUE: QueuedMessage[] = [];
|
||||||
|
|
||||||
export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsProps) => {
|
export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsProps) => {
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const queuedMessages = useMessageQueueStore(
|
const queuedMessages = useMessageQueueStore(
|
||||||
React.useCallback(
|
React.useCallback(
|
||||||
(state) => {
|
(state) => {
|
||||||
@@ -84,10 +86,9 @@ export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsPro
|
|||||||
|
|
||||||
const popped = popToInput(currentSessionId, message.id);
|
const popped = popToInput(currentSessionId, message.id);
|
||||||
if (popped) {
|
if (popped) {
|
||||||
// Restore attachments to file store if any
|
|
||||||
if (popped.attachments && popped.attachments.length > 0) {
|
if (popped.attachments && popped.attachments.length > 0) {
|
||||||
const currentAttachments = useFileStore.getState().attachedFiles;
|
const currentAttachments = useInputStore.getState().attachedFiles;
|
||||||
useFileStore.setState({
|
useInputStore.setState({
|
||||||
attachedFiles: [...currentAttachments, ...popped.attachments]
|
attachedFiles: [...currentAttachments, ...popped.attachments]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
|||||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||||
const [filteredSkills, setFilteredSkills] = React.useState<SkillInfo[]>([]);
|
const [filteredSkills, setFilteredSkills] = React.useState<SkillInfo[]>([]);
|
||||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const { skills, loadSkills } = useSkillsStore();
|
const skills = useSkillsStore((s) => s.skills);
|
||||||
|
const loadSkills = useSkillsStore((s) => s.loadSkills);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// Always trigger loadSkills when autocomplete opens to ensure project context is fresh
|
// Always trigger loadSkills when autocomplete opens to ensure project context is fresh
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useContextStore } from '@/stores/contextStore';
|
import { useContextStore } from '@/stores/contextStore';
|
||||||
import { formatEffortLabel, getAgentDisplayName, getModelDisplayName } from './mobileControlsUtils';
|
import { formatEffortLabel, getAgentDisplayName, getModelDisplayName } from './mobileControlsUtils';
|
||||||
|
|
||||||
|
const STATUS_CHIP_STYLE = {
|
||||||
|
height: '28px',
|
||||||
|
maxHeight: '28px',
|
||||||
|
minHeight: '28px',
|
||||||
|
};
|
||||||
|
|
||||||
interface StatusChipProps {
|
interface StatusChipProps {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StatusChip: React.FC<StatusChipProps> = ({ onClick, className }) => {
|
export const StatusChip: React.FC<StatusChipProps> = ({ onClick, className }) => {
|
||||||
const {
|
const currentModelId = useConfigStore((state) => state.currentModelId);
|
||||||
currentModelId,
|
const currentVariant = useConfigStore((state) => state.currentVariant);
|
||||||
currentVariant,
|
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
||||||
currentAgentName,
|
const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider);
|
||||||
getCurrentProvider,
|
const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants);
|
||||||
getCurrentModelVariants,
|
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
||||||
getVisibleAgents,
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
} = useConfigStore();
|
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
|
||||||
const sessionAgentName = useContextStore((state) =>
|
const sessionAgentName = useContextStore((state) =>
|
||||||
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
|
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)]',
|
'focus:outline-none hover:bg-[var(--interactive-hover)]',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{
|
style={STATUS_CHIP_STYLE}
|
||||||
height: '28px',
|
|
||||||
maxHeight: '28px',
|
|
||||||
minHeight: '28px',
|
|
||||||
}}
|
|
||||||
title={fullLabel}
|
title={fullLabel}
|
||||||
>
|
>
|
||||||
<span className="shrink-0">{agentLabel}</span>
|
<span className="shrink-0">{agentLabel}</span>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import {
|
import {
|
||||||
RiArrowDownSLine,
|
RiArrowDownSLine,
|
||||||
RiArrowUpDoubleLine,
|
RiArrowUpDoubleLine,
|
||||||
@@ -9,12 +10,21 @@ import {
|
|||||||
RiTimeLine,
|
RiTimeLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTodoStore, type TodoItem, type TodoPriority, type TodoStatus } from "@/stores/useTodoStore";
|
import { useDirectorySync } from "@/sync/sync-context";
|
||||||
import { useSessionStore } from "@/stores/useSessionStore";
|
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 { useUIStore } from "@/stores/useUIStore";
|
||||||
|
import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
|
||||||
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
|
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
|
||||||
import { isVSCodeRuntime } from "@/lib/desktop";
|
import { isVSCodeRuntime } from "@/lib/desktop";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
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 }> = {
|
const statusConfig: Record<TodoStatus, { textClassName: string }> = {
|
||||||
in_progress: {
|
in_progress: {
|
||||||
@@ -43,17 +53,17 @@ const priorityIcon: Record<TodoPriority, React.ReactNode> = {
|
|||||||
low: <RiArrowDownSLine className="h-3.5 w-3.5" aria-hidden="true" />,
|
low: <RiArrowDownSLine className="h-3.5 w-3.5" aria-hidden="true" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusLabel: Record<TodoStatus, string> = {
|
const statusLabelKey: Record<TodoStatus, string> = {
|
||||||
in_progress: "In progress",
|
in_progress: "chat.statusRow.todo.status.inProgress",
|
||||||
pending: "Pending",
|
pending: "chat.statusRow.todo.status.pending",
|
||||||
completed: "Completed",
|
completed: "chat.statusRow.todo.status.completed",
|
||||||
cancelled: "Cancelled",
|
cancelled: "chat.statusRow.todo.status.cancelled",
|
||||||
};
|
};
|
||||||
|
|
||||||
const priorityLabel: Record<TodoPriority, string> = {
|
const priorityLabelKey: Record<TodoPriority, string> = {
|
||||||
high: "High priority",
|
high: "chat.statusRow.todo.priority.high",
|
||||||
medium: "Medium priority",
|
medium: "chat.statusRow.todo.priority.medium",
|
||||||
low: "Low priority",
|
low: "chat.statusRow.todo.priority.low",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TodoItemRowProps {
|
interface TodoItemRowProps {
|
||||||
@@ -61,7 +71,10 @@ interface TodoItemRowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
|
const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const config = statusConfig[todo.status] || statusConfig.pending;
|
const config = statusConfig[todo.status] || statusConfig.pending;
|
||||||
|
const statusKey = statusLabelKey[todo.status] ?? statusLabelKey.pending;
|
||||||
|
const priorityKey = priorityLabelKey[todo.priority] ?? priorityLabelKey.medium;
|
||||||
|
|
||||||
const statusIcon =
|
const statusIcon =
|
||||||
todo.status === "in_progress" ? (
|
todo.status === "in_progress" ? (
|
||||||
@@ -79,7 +92,7 @@ const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
|
|||||||
<span className="flex-shrink-0">{statusIcon}</span>
|
<span className="flex-shrink-0">{statusIcon}</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="left" sideOffset={6}>
|
<TooltipContent side="left" sideOffset={6}>
|
||||||
{statusLabel[todo.status] ?? statusLabel.pending}
|
{t(statusKey as never)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<span
|
<span
|
||||||
@@ -102,7 +115,7 @@ const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
|
|||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" sideOffset={6}>
|
<TooltipContent side="right" sideOffset={6}>
|
||||||
{priorityLabel[todo.priority] ?? priorityLabel.medium}
|
{t(priorityKey as never)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,6 +141,7 @@ interface StatusRowProps {
|
|||||||
showAssistantStatus?: boolean;
|
showAssistantStatus?: boolean;
|
||||||
showTodos?: boolean;
|
showTodos?: boolean;
|
||||||
agentName?: string;
|
agentName?: string;
|
||||||
|
leftAccessory?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StatusRow: React.FC<StatusRowProps> = ({
|
export const StatusRow: React.FC<StatusRowProps> = ({
|
||||||
@@ -144,23 +158,27 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
showAssistantStatus = true,
|
showAssistantStatus = true,
|
||||||
showTodos = true,
|
showTodos = true,
|
||||||
agentName,
|
agentName,
|
||||||
|
leftAccessory,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const todos = useTodoStore((state) =>
|
const todosRecord = useDirectorySync((state) => state.todo);
|
||||||
currentSessionId ? state.sessionTodos.get(currentSessionId) ?? EMPTY_TODOS : EMPTY_TODOS
|
const persistedSessionTodos = useTodosPersistStore(
|
||||||
|
React.useCallback(
|
||||||
|
(state) => (currentSessionId ? state.sessions[currentSessionId]?.todos : undefined),
|
||||||
|
[currentSessionId],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const loadTodos = useTodoStore((state) => state.loadTodos);
|
const todos: TodoItem[] = React.useMemo(() => {
|
||||||
const { isMobile } = useUIStore();
|
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();
|
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.
|
// Filter out cancelled todos for display and keep original order.
|
||||||
// This prevents items from jumping around when status changes.
|
// This prevents items from jumping around when status changes.
|
||||||
const visibleTodos = React.useMemo(() => {
|
const visibleTodos = React.useMemo(() => {
|
||||||
@@ -189,17 +207,17 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
return { active, left };
|
return { active, left };
|
||||||
}, [visibleTodos]);
|
}, [visibleTodos]);
|
||||||
|
|
||||||
const hasActiveTodos = visibleTodos.some((t) => t.status === "in_progress" || t.status === "pending");
|
const hasTodoContent = showTodos && statusSummary.left > 0;
|
||||||
const hasTodoContent = showTodos && hasActiveTodos;
|
|
||||||
const hasAssistantContent = showAssistantStatus && (
|
const hasAssistantContent = showAssistantStatus && (
|
||||||
isWorking ||
|
isWorking ||
|
||||||
Boolean(wasAborted) ||
|
Boolean(wasAborted) ||
|
||||||
Boolean(showAbortStatus)
|
Boolean(showAbortStatus)
|
||||||
);
|
);
|
||||||
|
const hasLeftAccessory = Boolean(leftAccessory);
|
||||||
// Original logic from ChatInput
|
// Original logic from ChatInput
|
||||||
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
|
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
|
||||||
|
|
||||||
const hasContent = hasAssistantContent || hasTodoContent;
|
const hasContent = hasAssistantContent || hasTodoContent || hasLeftAccessory;
|
||||||
|
|
||||||
// Close popover when clicking outside
|
// Close popover when clicking outside
|
||||||
const popoverRef = React.useRef<HTMLDivElement>(null);
|
const popoverRef = React.useRef<HTMLDivElement>(null);
|
||||||
@@ -217,6 +235,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
}, [isExpanded]);
|
}, [isExpanded]);
|
||||||
|
|
||||||
const toggleExpanded = () => setIsExpanded((prev) => !prev);
|
const toggleExpanded = () => setIsExpanded((prev) => !prev);
|
||||||
|
const todoSummaryLabel = t('chat.statusRow.summary.activeLeft', {
|
||||||
|
active: statusSummary.active,
|
||||||
|
left: statusSummary.left,
|
||||||
|
});
|
||||||
|
|
||||||
// Abort button for mobile/vscode
|
// Abort button for mobile/vscode
|
||||||
const abortButton = showAbort && onAbort ? (
|
const abortButton = showAbort && onAbort ? (
|
||||||
@@ -224,7 +246,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onAbort}
|
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"
|
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" />
|
<RiCloseCircleLine size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
@@ -236,17 +258,27 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={toggleExpanded}
|
onClick={toggleExpanded}
|
||||||
className="flex items-center gap-1 flex-shrink-0 text-muted-foreground"
|
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" */}
|
{/* Desktop: show task text; Mobile/VSCode: just "Tasks" */}
|
||||||
{!isCompact && activeTodo ? (
|
{!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}
|
{activeTodo.content}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="typography-ui-label">Tasks</span>
|
<span className="typography-ui-label">{t('chat.statusRow.tasksTitle')}</span>
|
||||||
)}
|
)}
|
||||||
<span className="typography-meta">
|
<span className="typography-meta flex items-center gap-1 tabular-nums" aria-hidden="true">
|
||||||
{statusSummary.active} active · {statusSummary.left} left
|
<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>
|
</span>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<RiArrowUpSLine className="h-3.5 w-3.5" />
|
<RiArrowUpSLine className="h-3.5 w-3.5" />
|
||||||
@@ -262,15 +294,15 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-column mb-1" style={{ containerType: "inline-size" }}>
|
<div className={cn("mb-1", !hasLeftAccessory && "chat-column")} style={STATUS_ROW_CONTAINER_STYLE}>
|
||||||
<div className="flex items-center justify-between py-0.5 gap-2 h-[1.2rem]">
|
<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 */}
|
{/* Left: Abort status or Working placeholder or leftAccessory */}
|
||||||
<div className="flex-1 flex items-center overflow-hidden min-w-0">
|
<div className={cn("flex-1 flex items-center min-w-0", hasLeftAccessory ? "pl-1.5" : "overflow-hidden")}>
|
||||||
{showAssistantStatus && showAbortStatus ? (
|
{showAssistantStatus && showAbortStatus ? (
|
||||||
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
|
<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">
|
<span className="flex items-center gap-1.5 typography-ui-label">
|
||||||
<RiCloseCircleLine size={16} aria-hidden="true" />
|
<RiCloseCircleLine size={16} aria-hidden="true" />
|
||||||
Aborted
|
{t('chat.statusRow.aborted')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : showAssistantStatus && shouldRenderPlaceholder ? (
|
) : showAssistantStatus && shouldRenderPlaceholder ? (
|
||||||
@@ -283,38 +315,45 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
retryInfo={retryInfo}
|
retryInfo={retryInfo}
|
||||||
agentName={agentName}
|
agentName={agentName}
|
||||||
/>
|
/>
|
||||||
|
) : leftAccessory ? (
|
||||||
|
leftAccessory
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Abort (mobile only) + Todo */}
|
{/* 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}
|
{abortButton}
|
||||||
{todoTrigger}
|
{todoTrigger}
|
||||||
|
|
||||||
{/* Popover dropdown */}
|
{/* Popover dropdown */}
|
||||||
{isExpanded && hasActiveTodos && (
|
{isExpanded && hasTodoContent && (
|
||||||
<div
|
<div
|
||||||
style={{ maxWidth: "calc(100cqw - 4ch)" }}
|
style={{
|
||||||
|
maxWidth: "min(28rem, calc(100cqw - 4ch))",
|
||||||
|
backgroundColor: "var(--surface-elevated)",
|
||||||
|
color: "var(--surface-elevated-foreground)",
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute right-0 bottom-full mb-1 z-50",
|
"absolute right-0 bottom-full mb-1 z-50",
|
||||||
"w-max min-w-[200px]",
|
"w-max min-w-[200px] rounded-xl p-1",
|
||||||
"rounded-xl border border-border bg-background shadow-none",
|
"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",
|
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
|
||||||
"duration-150"
|
"duration-150"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
|
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
|
||||||
<span className="typography-ui-label text-muted-foreground">Tasks</span>
|
<span>{t('chat.statusRow.tasksTitle')}</span>
|
||||||
<span className="typography-meta text-muted-foreground">
|
<span className="typography-meta tabular-nums">
|
||||||
{progress.completed}/{progress.total}
|
{progress.completed}/{progress.total}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Todo list */}
|
{/* Todo list */}
|
||||||
<div className="px-3 py-2 max-h-[200px] overflow-y-auto divide-y divide-border">
|
<div className="px-1 max-h-[200px] overflow-y-auto">
|
||||||
{visibleTodos.map((todo) => (
|
{visibleTodos.map((todo, index) => (
|
||||||
<TodoItemRow key={todo.id} todo={todo} />
|
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useMessageStore } from '@/stores/messageStore';
|
import { useSessionMessageRecords } from '@/sync/sync-context';
|
||||||
import { RiLoader4Line, RiSearchLine, RiTimeLine, RiGitBranchLine, RiArrowGoBackLine } from '@remixicon/react';
|
import { RiLoader4Line, RiSearchLine, RiTimeLine, RiGitBranchLine, RiArrowGoBackLine } from '@remixicon/react';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import type { Part } from '@opencode-ai/sdk/v2';
|
import type { Part } from '@opencode-ai/sdk/v2';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
interface TimelineDialogProps {
|
interface TimelineDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -21,22 +22,6 @@ interface TimelineDialogProps {
|
|||||||
onResumeToLatest?: () => void;
|
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> = ({
|
export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -44,17 +29,30 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
|||||||
onScrollByTurnOffset,
|
onScrollByTurnOffset,
|
||||||
onResumeToLatest,
|
onResumeToLatest,
|
||||||
}) => {
|
}) => {
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const { t } = useI18n();
|
||||||
const messages = useMessageStore((state) =>
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
currentSessionId ? state.messages.get(currentSessionId) || [] : []
|
const messages = useSessionMessageRecords(currentSessionId ?? '');
|
||||||
);
|
const revertToMessage = useSessionUIStore((state) => state.revertToMessage);
|
||||||
const revertToMessage = useSessionStore((state) => state.revertToMessage);
|
const forkFromMessage = useSessionUIStore((state) => state.forkFromMessage);
|
||||||
const forkFromMessage = useSessionStore((state) => state.forkFromMessage);
|
|
||||||
const loadSessions = useSessionStore((state) => state.loadSessions);
|
|
||||||
|
|
||||||
const [forkingMessageId, setForkingMessageId] = React.useState<string | null>(null);
|
const [forkingMessageId, setForkingMessageId] = React.useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = React.useState('');
|
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)
|
// Filter user messages (reversed for newest first)
|
||||||
const userMessages = React.useMemo(() => {
|
const userMessages = React.useMemo(() => {
|
||||||
const filtered = messages.filter(m => m.info.role === 'user');
|
const filtered = messages.filter(m => m.info.role === 'user');
|
||||||
@@ -78,7 +76,6 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
|||||||
setForkingMessageId(messageId);
|
setForkingMessageId(messageId);
|
||||||
try {
|
try {
|
||||||
await forkFromMessage(currentSessionId, messageId);
|
await forkFromMessage(currentSessionId, messageId);
|
||||||
await loadSessions();
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} finally {
|
} finally {
|
||||||
setForkingMessageId(null);
|
setForkingMessageId(null);
|
||||||
@@ -93,17 +90,17 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<RiTimeLine className="h-5 w-5" />
|
<RiTimeLine className="h-5 w-5" />
|
||||||
Conversation Timeline
|
{t('chat.timeline.title')}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Navigate to any point in the conversation or fork a new session
|
{t('chat.timeline.description')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="relative mt-2">
|
<div className="relative mt-2">
|
||||||
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search messages..."
|
placeholder={t('chat.timeline.searchPlaceholder')}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-9 w-full"
|
className="pl-9 w-full"
|
||||||
@@ -113,7 +110,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
|||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{filteredMessages.length === 0 ? (
|
{filteredMessages.length === 0 ? (
|
||||||
<div className="text-center text-muted-foreground py-8">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredMessages.map((message) => {
|
filteredMessages.map((message) => {
|
||||||
@@ -138,7 +135,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
|||||||
{messageNumber}.
|
{messageNumber}.
|
||||||
</span>
|
</span>
|
||||||
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
|
<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 && '…'}
|
{preview && preview.length >= 80 && '…'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -162,7 +159,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
|||||||
<RiArrowGoBackLine className="h-4 w-4" />
|
<RiArrowGoBackLine className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent sideOffset={6}>Revert from here</TooltipContent>
|
<TooltipContent sideOffset={6}>{t('chat.timeline.actions.revertFromHere')}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip delayDuration={1000}>
|
<Tooltip delayDuration={1000}>
|
||||||
@@ -183,7 +180,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent sideOffset={6}>Fork from here</TooltipContent>
|
<TooltipContent sideOffset={6}>{t('chat.timeline.actions.forkFromHere')}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,7 +191,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
|
<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">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -204,7 +201,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Previous turn
|
{t('chat.timeline.actions.previousTurn')}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-muted-foreground/50">/</span>
|
<span className="text-muted-foreground/50">/</span>
|
||||||
<button
|
<button
|
||||||
@@ -215,20 +212,20 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Latest
|
{t('chat.timeline.actions.latest')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5 typography-meta text-muted-foreground">
|
<div className="flex flex-col gap-1.5 typography-meta text-muted-foreground">
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<RiArrowGoBackLine className="h-4 w-4 flex-shrink-0" />
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<RiGitBranchLine className="h-4 w-4 flex-shrink-0" />
|
<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>
|
</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 { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
interface ScrollToBottomButtonProps {
|
interface ScrollToBottomButtonProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -10,6 +11,7 @@ interface ScrollToBottomButtonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, onClick }) => {
|
const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, onClick }) => {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -21,8 +23,8 @@ const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, on
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="rounded-full h-8 w-8 p-0 shadow-none bg-background/95 hover:bg-interactive-hover"
|
className="size-8 rounded-full [corner-shape:round] p-0 shadow-none bg-background/95 hover:bg-interactive-hover"
|
||||||
aria-label="Scroll to bottom"
|
aria-label={t('chat.scrollToBottom.aria')}
|
||||||
>
|
>
|
||||||
<RiArrowDownLine className="h-4 w-4" />
|
<RiArrowDownLine className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ interface TurnActivityProps {
|
|||||||
animateRows?: boolean;
|
animateRows?: boolean;
|
||||||
animatedToolIds?: Set<string>;
|
animatedToolIds?: Set<string>;
|
||||||
diffStats?: DiffStats;
|
diffStats?: DiffStats;
|
||||||
|
renderJustificationActions?: (activity: TurnActivityRecord) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TurnActivity: React.FC<TurnActivityProps> = (props) => {
|
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 => {
|
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;
|
export default React.memo(TurnList) as typeof TurnList;
|
||||||
|
|||||||
@@ -7,25 +7,24 @@ import {
|
|||||||
buildTurnWindowModel,
|
buildTurnWindowModel,
|
||||||
clampTurnStart,
|
clampTurnStart,
|
||||||
getInitialTurnStart,
|
getInitialTurnStart,
|
||||||
|
updateTurnWindowModelIncremental,
|
||||||
windowMessagesByTurn,
|
windowMessagesByTurn,
|
||||||
type TurnWindowModel,
|
type TurnWindowModel,
|
||||||
} from '../lib/turns/windowTurns';
|
} from '../lib/turns/windowTurns';
|
||||||
import type { TurnHistorySignals } from '../lib/turns/historySignals';
|
import type { TurnHistorySignals } from '../lib/turns/historySignals';
|
||||||
import { getMemoryLimits, type SessionHistoryMeta } from '@/stores/types/sessionTypes';
|
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 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 {
|
interface UseChatTimelineControllerOptions {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
messages: ChatMessageEntry[];
|
messages: ChatMessageEntry[];
|
||||||
@@ -33,7 +32,8 @@ interface UseChatTimelineControllerOptions {
|
|||||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||||
messageListRef: React.RefObject<MessageListHandle | null>;
|
messageListRef: React.RefObject<MessageListHandle | null>;
|
||||||
loadMoreMessages: (sessionId: string, direction: 'up' | 'down') => Promise<void>;
|
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;
|
isPinned: boolean;
|
||||||
isOverflowing: boolean;
|
isOverflowing: boolean;
|
||||||
}
|
}
|
||||||
@@ -66,11 +66,24 @@ export const useChatTimelineController = ({
|
|||||||
scrollRef,
|
scrollRef,
|
||||||
messageListRef,
|
messageListRef,
|
||||||
loadMoreMessages,
|
loadMoreMessages,
|
||||||
|
prepareForBottomResume,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
isPinned,
|
isPinned,
|
||||||
isOverflowing,
|
isOverflowing,
|
||||||
}: UseChatTimelineControllerOptions): UseChatTimelineControllerResult => {
|
}: 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 [turnStart, setTurnStart] = React.useState(() => getInitialTurnStart(turnWindowModel.turnCount));
|
||||||
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
|
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
|
||||||
@@ -87,6 +100,8 @@ export const useChatTimelineController = ({
|
|||||||
const historyMetaRef = React.useRef<SessionHistoryMeta | null>(historyMeta);
|
const historyMetaRef = React.useRef<SessionHistoryMeta | null>(historyMeta);
|
||||||
const previousTurnCountRef = React.useRef(turnWindowModel.turnCount);
|
const previousTurnCountRef = React.useRef(turnWindowModel.turnCount);
|
||||||
const initializedSessionRef = React.useRef<string | null>(null);
|
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 historySignals = React.useMemo(() => {
|
||||||
const defaultLimit = getMemoryLimits().HISTORICAL_MESSAGES;
|
const defaultLimit = getMemoryLimits().HISTORICAL_MESSAGES;
|
||||||
@@ -105,43 +120,17 @@ export const useChatTimelineController = ({
|
|||||||
|
|
||||||
const historySignalsRef = React.useRef(historySignals);
|
const historySignalsRef = React.useRef(historySignals);
|
||||||
|
|
||||||
React.useEffect(() => {
|
turnModelRef.current = turnWindowModel;
|
||||||
turnModelRef.current = turnWindowModel;
|
turnStartRef.current = turnStart;
|
||||||
}, [turnWindowModel]);
|
isPinnedRef.current = isPinned;
|
||||||
|
isLoadingOlderRef.current = isLoadingOlder;
|
||||||
|
pendingRevealWorkRef.current = pendingRevealWork;
|
||||||
|
historySignalsRef.current = historySignals;
|
||||||
|
sessionIdRef.current = sessionId;
|
||||||
|
messagesRef.current = messages;
|
||||||
|
historyMetaRef.current = historyMeta;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
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(() => {
|
|
||||||
if (initializedSessionRef.current === sessionId) {
|
if (initializedSessionRef.current === sessionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -153,11 +142,11 @@ export const useChatTimelineController = ({
|
|||||||
previousTurnCountRef.current = turnWindowModel.turnCount;
|
previousTurnCountRef.current = turnWindowModel.turnCount;
|
||||||
}, [sessionId, turnWindowModel.turnCount]);
|
}, [sessionId, turnWindowModel.turnCount]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
setTurnStart((current) => clampTurnStart(current, turnWindowModel.turnCount));
|
setTurnStart((current) => clampTurnStart(current, turnWindowModel.turnCount));
|
||||||
}, [turnWindowModel.turnCount]);
|
}, [turnWindowModel.turnCount]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
const previousTurnCount = previousTurnCountRef.current;
|
const previousTurnCount = previousTurnCountRef.current;
|
||||||
const nextTurnCount = turnWindowModel.turnCount;
|
const nextTurnCount = turnWindowModel.turnCount;
|
||||||
if (previousTurnCount === nextTurnCount) {
|
if (previousTurnCount === nextTurnCount) {
|
||||||
@@ -176,10 +165,114 @@ export const useChatTimelineController = ({
|
|||||||
previousTurnCountRef.current = nextTurnCount;
|
previousTurnCountRef.current = nextTurnCount;
|
||||||
}, [turnWindowModel.turnCount]);
|
}, [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(() => {
|
const renderedMessages = React.useMemo(() => {
|
||||||
return windowMessagesByTurn(messages, turnWindowModel, turnStart);
|
return windowMessagesByTurn(messages, turnWindowModel, turnStart);
|
||||||
}, [messages, turnStart, turnWindowModel]);
|
}, [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 => {
|
const captureViewportAnchor = React.useCallback((): ViewportAnchor | null => {
|
||||||
return messageListRef.current?.captureViewportAnchor() ?? null;
|
return messageListRef.current?.captureViewportAnchor() ?? null;
|
||||||
}, [messageListRef]);
|
}, [messageListRef]);
|
||||||
@@ -188,35 +281,19 @@ export const useChatTimelineController = ({
|
|||||||
return messageListRef.current?.restoreViewportAnchor(anchor) ?? false;
|
return messageListRef.current?.restoreViewportAnchor(anchor) ?? false;
|
||||||
}, [messageListRef]);
|
}, [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> => {
|
const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => {
|
||||||
if (turnStartRef.current <= 0 || pendingRevealWorkRef.current) {
|
if (turnStartRef.current <= 0 || pendingRevealWorkRef.current) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const anchor = captureViewportAnchor();
|
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
const previousHeight = container?.scrollHeight ?? null;
|
if (container) {
|
||||||
const previousTop = container?.scrollTop ?? null;
|
prePrependScrollRef.current = {
|
||||||
|
height: container.scrollHeight,
|
||||||
|
top: container.scrollTop,
|
||||||
|
anchor: captureViewportAnchor(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
setPendingRevealWork(true);
|
setPendingRevealWork(true);
|
||||||
setTurnStart((current) => {
|
setTurnStart((current) => {
|
||||||
@@ -224,15 +301,10 @@ export const useChatTimelineController = ({
|
|||||||
return next > 0 ? next : 0;
|
return next > 0 ? next : 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForFrames(1);
|
await waitForNextRenderCommit();
|
||||||
restoreViewportWithFallback({
|
|
||||||
anchor,
|
|
||||||
previousHeight,
|
|
||||||
previousTop,
|
|
||||||
});
|
|
||||||
setPendingRevealWork(false);
|
setPendingRevealWork(false);
|
||||||
return true;
|
return true;
|
||||||
}, [captureViewportAnchor, restoreViewportWithFallback, scrollRef]);
|
}, [captureViewportAnchor, scrollRef, waitForNextRenderCommit]);
|
||||||
|
|
||||||
const fetchOlderHistory = React.useCallback(async (input: {
|
const fetchOlderHistory = React.useCallback(async (input: {
|
||||||
preserveViewport: boolean;
|
preserveViewport: boolean;
|
||||||
@@ -244,16 +316,22 @@ export const useChatTimelineController = ({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const anchor = input.preserveViewport ? captureViewportAnchor() : null;
|
|
||||||
const container = scrollRef.current;
|
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 beforeMessages = messagesRef.current;
|
||||||
const beforeMessageCount = beforeMessages.length;
|
const beforeMessageCount = beforeMessages.length;
|
||||||
const beforeOldestMessageId = beforeMessages[0]?.info?.id ?? null;
|
const beforeOldestMessageId = beforeMessages[0]?.info?.id ?? null;
|
||||||
const beforeLimit = historyMetaRef.current?.limit ?? getMemoryLimits().HISTORICAL_MESSAGES;
|
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);
|
setIsLoadingOlder(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -274,20 +352,11 @@ export const useChatTimelineController = ({
|
|||||||
&& typeof afterOldestMessageId === 'string'
|
&& typeof afterOldestMessageId === 'string'
|
||||||
&& beforeOldestMessageId !== afterOldestMessageId);
|
&& beforeOldestMessageId !== afterOldestMessageId);
|
||||||
|
|
||||||
if (input.preserveViewport) {
|
|
||||||
restoreViewportWithFallback({
|
|
||||||
anchor,
|
|
||||||
previousHeight,
|
|
||||||
previousTop,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return historyGrew || afterLimit > beforeLimit;
|
return historyGrew || afterLimit > beforeLimit;
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingOlder(false);
|
setIsLoadingOlder(false);
|
||||||
setPendingRevealWork(false);
|
|
||||||
}
|
}
|
||||||
}, [captureViewportAnchor, loadMoreMessages, restoreViewportWithFallback, scrollRef]);
|
}, [captureViewportAnchor, loadMoreMessages, scrollRef]);
|
||||||
|
|
||||||
const loadEarlier = React.useCallback(async () => {
|
const loadEarlier = React.useCallback(async () => {
|
||||||
if (await revealBufferedTurns()) {
|
if (await revealBufferedTurns()) {
|
||||||
@@ -319,26 +388,29 @@ export const useChatTimelineController = ({
|
|||||||
|
|
||||||
if (turnIndex < turnStartRef.current) {
|
if (turnIndex < turnStartRef.current) {
|
||||||
setTurnStart(turnIndex);
|
setTurnStart(turnIndex);
|
||||||
await waitForFrames(2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const didScroll = messageListRef.current?.scrollToTurnId(turnId, {
|
const result = await new Promise<boolean>((resolve) => {
|
||||||
behavior: options?.behavior,
|
pendingScrollRequestRef.current = {
|
||||||
}) ?? false;
|
sessionId: sessionIdRef.current ?? sessionId ?? '',
|
||||||
|
kind: 'turn',
|
||||||
|
id: turnId,
|
||||||
|
behavior: options?.behavior ?? 'auto',
|
||||||
|
turnId,
|
||||||
|
resolve,
|
||||||
|
};
|
||||||
|
attemptPendingScrollRequest();
|
||||||
|
});
|
||||||
|
|
||||||
if (didScroll) {
|
if (result) {
|
||||||
setActiveTurnId(turnId);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
await waitForFrames(2);
|
return false;
|
||||||
return messageListRef.current?.scrollToTurnId(turnId, {
|
|
||||||
behavior: options?.behavior,
|
|
||||||
}) ?? false;
|
|
||||||
} finally {
|
} finally {
|
||||||
setPendingRevealWork(false);
|
setPendingRevealWork(false);
|
||||||
}
|
}
|
||||||
}, [messageListRef, sessionId]);
|
}, [attemptPendingScrollRequest, sessionId]);
|
||||||
|
|
||||||
const scrollToMessage = React.useCallback(async (
|
const scrollToMessage = React.useCallback(async (
|
||||||
messageId: string,
|
messageId: string,
|
||||||
@@ -364,44 +436,59 @@ export const useChatTimelineController = ({
|
|||||||
|
|
||||||
if (turnIndex < turnStartRef.current) {
|
if (turnIndex < turnStartRef.current) {
|
||||||
setTurnStart(turnIndex);
|
setTurnStart(turnIndex);
|
||||||
await waitForFrames(2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const didScroll = messageListRef.current?.scrollToMessageId(messageId, {
|
const result = await new Promise<boolean>((resolve) => {
|
||||||
behavior: options?.behavior,
|
pendingScrollRequestRef.current = {
|
||||||
}) ?? false;
|
sessionId: sessionIdRef.current ?? sessionId ?? '',
|
||||||
|
kind: 'message',
|
||||||
|
id: messageId,
|
||||||
|
behavior: options?.behavior ?? 'auto',
|
||||||
|
turnId: turnId ?? null,
|
||||||
|
resolve,
|
||||||
|
};
|
||||||
|
attemptPendingScrollRequest();
|
||||||
|
});
|
||||||
|
|
||||||
if (didScroll) {
|
if (result) {
|
||||||
if (turnId) {
|
|
||||||
setActiveTurnId(turnId);
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
await waitForFrames(2);
|
return false;
|
||||||
return messageListRef.current?.scrollToMessageId(messageId, {
|
|
||||||
behavior: options?.behavior,
|
|
||||||
}) ?? false;
|
|
||||||
} finally {
|
} finally {
|
||||||
setPendingRevealWork(false);
|
setPendingRevealWork(false);
|
||||||
}
|
}
|
||||||
}, [messageListRef, sessionId]);
|
}, [attemptPendingScrollRequest, sessionId]);
|
||||||
|
|
||||||
const resumeToBottom = React.useCallback(() => {
|
const resumeToBottom = React.useCallback(async () => {
|
||||||
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
|
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
|
||||||
setTurnStart(nextStart);
|
|
||||||
setPendingRevealWork(false);
|
setPendingRevealWork(false);
|
||||||
setIsLoadingOlder(false);
|
setIsLoadingOlder(false);
|
||||||
|
prepareForBottomResume({ force: true });
|
||||||
|
|
||||||
|
const shouldWaitForRender = nextStart !== turnStartRef.current;
|
||||||
|
if (shouldWaitForRender) {
|
||||||
|
setTurnStart(nextStart);
|
||||||
|
await waitForNextRenderCommit();
|
||||||
|
}
|
||||||
|
|
||||||
scrollToBottom({ force: true });
|
scrollToBottom({ force: true });
|
||||||
}, [scrollToBottom]);
|
}, [prepareForBottomResume, scrollToBottom, waitForNextRenderCommit]);
|
||||||
|
|
||||||
const resumeToBottomInstant = React.useCallback(() => {
|
const resumeToBottomInstant = React.useCallback(async () => {
|
||||||
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
|
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
|
||||||
setTurnStart(nextStart);
|
|
||||||
setPendingRevealWork(false);
|
setPendingRevealWork(false);
|
||||||
setIsLoadingOlder(false);
|
setIsLoadingOlder(false);
|
||||||
scrollToBottom({ instant: true, force: true });
|
prepareForBottomResume({ instant: true, force: true });
|
||||||
}, [scrollToBottom]);
|
|
||||||
|
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) => {
|
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
|
||||||
setActiveTurnId(turnId);
|
setActiveTurnId(turnId);
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ interface UseChatTurnNavigationOptions {
|
|||||||
export interface ChatTurnNavigation {
|
export interface ChatTurnNavigation {
|
||||||
scrollToTurnId: (turnId: string, options?: { behavior?: ScrollBehavior; updateHash?: boolean }) => Promise<boolean>;
|
scrollToTurnId: (turnId: string, options?: { behavior?: ScrollBehavior; updateHash?: boolean }) => Promise<boolean>;
|
||||||
scrollToMessageId: (messageId: 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;
|
resumeToLatest: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,14 +133,23 @@ export const useChatTurnNavigation = ({
|
|||||||
return scrollToMessage(messageId, { behavior: options?.behavior });
|
return scrollToMessage(messageId, { behavior: options?.behavior });
|
||||||
}, [scrollToMessage]);
|
}, [scrollToMessage]);
|
||||||
|
|
||||||
const scrollByTurnOffset = React.useCallback(async (offset: number): Promise<boolean> => {
|
const scrollByTurnOffset = React.useCallback(async (
|
||||||
const target = resolveTurnOffsetTarget(turnIdsRef.current, activeTurnIdRef.current, offset);
|
offset: number,
|
||||||
|
options?: { resumePastEnd?: boolean },
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const turnIds = turnIdsRef.current;
|
||||||
|
const target = resolveTurnOffsetTarget(turnIds, activeTurnIdRef.current, offset);
|
||||||
|
|
||||||
if (target.kind === 'noop') {
|
if (target.kind === 'noop') {
|
||||||
return offset === 0;
|
return offset === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target.kind === 'resume') {
|
if (target.kind === 'resume') {
|
||||||
|
if (options?.resumePastEnd === false) {
|
||||||
|
const lastTurnId = turnIds[turnIds.length - 1];
|
||||||
|
return lastTurnId ? scrollToTurnId(lastTurnId, { behavior: 'auto' }) : false;
|
||||||
|
}
|
||||||
|
|
||||||
setHash(null);
|
setHash(null);
|
||||||
resumeToBottom();
|
resumeToBottom();
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const useStreamingTextThrottle = ({
|
|||||||
}: UseStreamingTextThrottleInput): string => {
|
}: UseStreamingTextThrottleInput): string => {
|
||||||
const [throttledText, setThrottledText] = React.useState(text);
|
const [throttledText, setThrottledText] = React.useState(text);
|
||||||
const latestTextRef = React.useRef(text);
|
const latestTextRef = React.useRef(text);
|
||||||
|
const throttledTextRef = React.useRef(throttledText);
|
||||||
|
|
||||||
const stateRef = React.useRef<StreamingThrottleState>({
|
const stateRef = React.useRef<StreamingThrottleState>({
|
||||||
timer: null,
|
timer: null,
|
||||||
@@ -47,6 +48,10 @@ export const useStreamingTextThrottle = ({
|
|||||||
latestTextRef.current = text;
|
latestTextRef.current = text;
|
||||||
}, [text]);
|
}, [text]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
throttledTextRef.current = throttledText;
|
||||||
|
}, [throttledText]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const state = stateRef.current;
|
const state = stateRef.current;
|
||||||
clearTimer(state);
|
clearTimer(state);
|
||||||
@@ -58,7 +63,8 @@ export const useStreamingTextThrottle = ({
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const state = stateRef.current;
|
const state = stateRef.current;
|
||||||
state.pendingText = text;
|
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) {
|
if (!isStreaming) {
|
||||||
clearTimer(state);
|
clearTimer(state);
|
||||||
@@ -92,7 +98,7 @@ export const useStreamingTextThrottle = ({
|
|||||||
return () => {
|
return () => {
|
||||||
clearTimer(state);
|
clearTimer(state);
|
||||||
};
|
};
|
||||||
}, [isStreaming, text, throttleMs, throttledText]);
|
}, [isStreaming, text, throttleMs]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const state = stateRef.current;
|
const state = stateRef.current;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { projectTurnRecords } from '../lib/turns/projectTurnRecords';
|
import { projectTurnRecords } from '../lib/turns/projectTurnRecords';
|
||||||
import { stabilizeTurnProjection } from '../lib/turns/stabilizeTurnProjection';
|
import type { ChatMessageEntry, TurnProjectionResult, TurnRecord } from '../lib/turns/types';
|
||||||
import type { ChatMessageEntry, TurnProjectionResult } from '../lib/turns/types';
|
import { streamPerfMeasure } from '@/stores/utils/streamDebug';
|
||||||
|
|
||||||
interface UseTurnRecordsOptions {
|
interface UseTurnRecordsOptions {
|
||||||
|
sessionKey?: string;
|
||||||
showTextJustificationActivity: boolean;
|
showTextJustificationActivity: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,33 +19,66 @@ export const useTurnRecords = (
|
|||||||
options: UseTurnRecordsOptions,
|
options: UseTurnRecordsOptions,
|
||||||
): TurnRecordsResult => {
|
): TurnRecordsResult => {
|
||||||
const previousProjectionRef = React.useRef<TurnProjectionResult | null>(null);
|
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(() => {
|
React.useEffect(() => {
|
||||||
previousProjectionRef.current = null;
|
previousProjectionRef.current = null;
|
||||||
}, [options.showTextJustificationActivity]);
|
staticTurnsRef.current = [];
|
||||||
|
streamingTurnRef.current = undefined;
|
||||||
|
}, [options.sessionKey, options.showTextJustificationActivity]);
|
||||||
|
|
||||||
const projection = React.useMemo(() => {
|
const projection = React.useMemo(() => {
|
||||||
const rawProjection = projectTurnRecords(messages, {
|
return streamPerfMeasure('ui.turns.projection_ms', () => {
|
||||||
previousProjection: previousProjectionRef.current,
|
const nextProjection = projectTurnRecords(messages, {
|
||||||
showTextJustificationActivity: options.showTextJustificationActivity,
|
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]);
|
}, [messages, options.showTextJustificationActivity]);
|
||||||
|
|
||||||
const staticTurns = React.useMemo(() => {
|
const staticTurns = React.useMemo(() => {
|
||||||
if (projection.turns.length <= 1) {
|
const nextStatic = projection.turns.length <= 1
|
||||||
return [];
|
? []
|
||||||
|
: 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]);
|
}, [projection.turns]);
|
||||||
|
|
||||||
const streamingTurn = React.useMemo(() => {
|
const streamingTurn = React.useMemo(() => {
|
||||||
if (projection.turns.length === 0) {
|
const nextStreamingTurn = projection.turns.length === 0
|
||||||
return undefined;
|
? 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]);
|
}, [projection.turns]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -13,11 +13,6 @@ export const collectVisibleSessionIdsForBlockingRequests = (
|
|||||||
const current = sessions.find((session) => session.id === currentSessionId);
|
const current = sessions.find((session) => session.id === currentSessionId);
|
||||||
if (!current) return [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[]>();
|
const childrenByParent = new Map<string, string[]>();
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
if (!session.parentID) {
|
if (!session.parentID) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { SessionMemoryState } from '@/stores/types/sessionTypes';
|
import type { SessionMemoryState } from '@/sync/viewport-store';
|
||||||
|
|
||||||
export interface TurnHistorySignalsInput {
|
export interface TurnHistorySignalsInput {
|
||||||
memoryState: SessionMemoryState | null;
|
memoryState: SessionMemoryState | null;
|
||||||
|
|||||||
@@ -36,19 +36,6 @@ const getMessageFinish = (message: ChatMessageEntry): string | undefined => {
|
|||||||
return typeof finish === 'string' ? finish : 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 = (
|
const buildTurnPartRecord = (
|
||||||
turnId: string,
|
turnId: string,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
@@ -69,6 +56,7 @@ interface ProjectActivityInput {
|
|||||||
turnId: string;
|
turnId: string;
|
||||||
assistantMessages: ChatMessageEntry[];
|
assistantMessages: ChatMessageEntry[];
|
||||||
summarySourceMessageId?: string;
|
summarySourceMessageId?: string;
|
||||||
|
summarySourcePartId?: string;
|
||||||
showTextJustificationActivity: boolean;
|
showTextJustificationActivity: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,35 +72,42 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
|
|||||||
let hasTools = false;
|
let hasTools = false;
|
||||||
let hasReasoning = 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 taskMessageById = new Map<string, string>();
|
||||||
const taskOrder: string[] = [];
|
const taskOrder: string[] = [];
|
||||||
const partsByAfterTool = new Map<string | null, TurnActivityRecord[]>();
|
const partsByAfterTool = new Map<string | null, TurnActivityRecord[]>();
|
||||||
let currentAfterToolPartId: string | null = null;
|
let currentAfterToolPartId: string | null = null;
|
||||||
|
|
||||||
input.assistantMessages.forEach((message) => {
|
input.assistantMessages.forEach((message) => {
|
||||||
const messageCompleted = isAssistantMessageCompleted(message);
|
|
||||||
const finish = getMessageFinish(message);
|
const finish = getMessageFinish(message);
|
||||||
|
const messageHasTool = message.parts.some((part) => part.type === 'tool');
|
||||||
|
|
||||||
message.parts.forEach((part, partIndex) => {
|
message.parts.forEach((part, partIndex) => {
|
||||||
const isTool = part.type === 'tool';
|
const isTool = part.type === 'tool';
|
||||||
if (isTool) {
|
|
||||||
hasTools = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = part.type === 'reasoning' || part.type === 'text'
|
const text = part.type === 'reasoning' || part.type === 'text'
|
||||||
? getPartText(part)
|
? getPartText(part)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const partId = part.id ?? `${message.info.id}-part-${partIndex}-${part.type}`;
|
||||||
if (part.type === 'reasoning' && text) {
|
|
||||||
hasReasoning = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolName = isTool
|
const toolName = isTool
|
||||||
? (part as { tool?: unknown }).tool
|
? (part as { tool?: unknown }).tool
|
||||||
: undefined;
|
: undefined;
|
||||||
const standaloneTool = isTool && isStandaloneTool(toolName);
|
const standaloneTool = isTool && isStandaloneTool(toolName);
|
||||||
if (standaloneTool) {
|
if (standaloneTool) {
|
||||||
const toolPartId = part.id ?? `${message.info.id}-part-${partIndex}-${part.type}`;
|
const toolPartId = partId;
|
||||||
if (!taskMessageById.has(toolPartId)) {
|
if (!taskMessageById.has(toolPartId)) {
|
||||||
taskMessageById.set(toolPartId, message.info.id);
|
taskMessageById.set(toolPartId, message.info.id);
|
||||||
taskOrder.push(toolPartId);
|
taskOrder.push(toolPartId);
|
||||||
@@ -120,6 +115,12 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
|
|||||||
currentAfterToolPartId = toolPartId;
|
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;
|
let kind: TurnActivityRecord['kind'] | null = null;
|
||||||
if (isTool) {
|
if (isTool) {
|
||||||
kind = 'tool';
|
kind = 'tool';
|
||||||
@@ -130,10 +131,9 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
|
|||||||
} else if (
|
} else if (
|
||||||
input.showTextJustificationActivity
|
input.showTextJustificationActivity
|
||||||
&& part.type === 'text'
|
&& part.type === 'text'
|
||||||
&& messageCompleted
|
|
||||||
&& typeof finish === 'string'
|
|
||||||
&& finish !== 'stop'
|
|
||||||
&& text
|
&& text
|
||||||
|
&& !isConfirmedSummaryText
|
||||||
|
&& (messageHasTool || (typeof finish === 'string' && finish !== 'stop'))
|
||||||
) {
|
) {
|
||||||
kind = 'justification';
|
kind = 'justification';
|
||||||
}
|
}
|
||||||
@@ -171,16 +171,11 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
|
|||||||
});
|
});
|
||||||
|
|
||||||
let firstWithAny: string | undefined;
|
let firstWithAny: string | undefined;
|
||||||
let cumulative = 0;
|
|
||||||
for (const message of input.assistantMessages) {
|
for (const message of input.assistantMessages) {
|
||||||
const count = countByMessage.get(message.info.id) ?? 0;
|
const count = countByMessage.get(message.info.id) ?? 0;
|
||||||
if (count > 0 && !firstWithAny) {
|
if (count > 0 && !firstWithAny) {
|
||||||
firstWithAny = message.info.id;
|
firstWithAny = message.info.id;
|
||||||
}
|
}
|
||||||
cumulative += count;
|
|
||||||
if (cumulative >= 2) {
|
|
||||||
return message.info.id;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return firstWithAny;
|
return firstWithAny;
|
||||||
|
|||||||
@@ -32,99 +32,6 @@ const getMessageCompletedAt = (message: ChatMessageEntry): number | undefined =>
|
|||||||
return typeof completed === 'number' ? completed : 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 getUserSummaryBody = (message: ChatMessageEntry): string | undefined => {
|
||||||
const summaryBody = (message.info as { summary?: { body?: unknown } | null | undefined })?.summary?.body;
|
const summaryBody = (message.info as { summary?: { body?: unknown } | null | undefined })?.summary?.body;
|
||||||
if (typeof summaryBody !== 'string') {
|
if (typeof summaryBody !== 'string') {
|
||||||
@@ -194,7 +101,6 @@ export const projectTurnRecords = (
|
|||||||
|
|
||||||
const turns: TurnRecord[] = [];
|
const turns: TurnRecord[] = [];
|
||||||
const turnByUserId = new Map<string, TurnRecord>();
|
const turnByUserId = new Map<string, TurnRecord>();
|
||||||
const previousTurnsById = new Map((effectiveOptions.previousProjection?.turns ?? []).map((turn) => [turn.turnId, turn]));
|
|
||||||
const groupedMessageIds = new Set<string>();
|
const groupedMessageIds = new Set<string>();
|
||||||
let currentTurn: TurnRecord | undefined;
|
let currentTurn: TurnRecord | undefined;
|
||||||
|
|
||||||
@@ -254,43 +160,6 @@ export const projectTurnRecords = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
turns.forEach((turn) => {
|
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.summary = projectTurnSummary(turn.assistantMessages);
|
||||||
turn.summaryText = turn.summary.text ?? getUserSummaryBody(turn.userMessage);
|
turn.summaryText = turn.summary.text ?? getUserSummaryBody(turn.userMessage);
|
||||||
turn.diffStats = projectTurnDiffStats(turn.userMessage);
|
turn.diffStats = projectTurnDiffStats(turn.userMessage);
|
||||||
@@ -299,6 +168,7 @@ export const projectTurnRecords = (
|
|||||||
turnId: turn.turnId,
|
turnId: turn.turnId,
|
||||||
assistantMessages: turn.assistantMessages,
|
assistantMessages: turn.assistantMessages,
|
||||||
summarySourceMessageId: turn.summary.sourceMessageId,
|
summarySourceMessageId: turn.summary.sourceMessageId,
|
||||||
|
summarySourcePartId: turn.summary.sourcePartId,
|
||||||
showTextJustificationActivity: effectiveOptions.showTextJustificationActivity,
|
showTextJustificationActivity: effectiveOptions.showTextJustificationActivity,
|
||||||
});
|
});
|
||||||
turn.activityParts = activity.activityParts;
|
turn.activityParts = activity.activityParts;
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
import { projectTurnIndexes } from './projectTurnIndexes';
|
import { projectTurnIndexes } from './projectTurnIndexes';
|
||||||
import type { TurnProjectionResult, TurnRecord } from './types';
|
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 buildTurnSignature = (turn: TurnRecord): string => {
|
||||||
const assistantIds = turn.assistantMessageIds.join(',');
|
const assistantIds = turn.assistantMessageIds.join(',');
|
||||||
return [
|
return [
|
||||||
@@ -40,6 +58,10 @@ export const stabilizeTurnProjection = (
|
|||||||
return turn;
|
return turn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!areTurnMessagesReferenceStable(previousTurn, turn)) {
|
||||||
|
return turn;
|
||||||
|
}
|
||||||
|
|
||||||
reused = true;
|
reused = true;
|
||||||
return previousTurn;
|
return previousTurn;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ export interface StageTurnsResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_STAGE_CONFIG: TurnStageConfig = {
|
const DEFAULT_STAGE_CONFIG: TurnStageConfig = {
|
||||||
init: 1,
|
init: 10,
|
||||||
batch: 3,
|
batch: 8,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getInitialStageCount = (total: number, config: TurnStageConfig): number => {
|
export const getInitialStageCount = (total: number, config: TurnStageConfig): number => {
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export type Turn = Pick<TurnRecord, 'turnId' | 'userMessage' | 'assistantMessage
|
|||||||
|
|
||||||
export interface TurnGroupingContext {
|
export interface TurnGroupingContext {
|
||||||
turnId: string;
|
turnId: string;
|
||||||
|
activityOwnerMessageId?: string;
|
||||||
isFirstAssistantInTurn: boolean;
|
isFirstAssistantInTurn: boolean;
|
||||||
isLastAssistantInTurn: boolean;
|
isLastAssistantInTurn: boolean;
|
||||||
summaryBody?: string;
|
summaryBody?: string;
|
||||||
|
|||||||
@@ -23,6 +23,119 @@ export interface TurnWindowModel {
|
|||||||
turnCount: number;
|
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 => {
|
export const buildTurnWindowModel = (messages: ChatMessageEntry[]): TurnWindowModel => {
|
||||||
const turnIds: string[] = [];
|
const turnIds: string[] = [];
|
||||||
const turnMessageStartIndexes: number[] = [];
|
const turnMessageStartIndexes: number[] = [];
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
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 { 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 { cn } from '@/lib/utils';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
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 {
|
interface TextSelectionMenuProps {
|
||||||
containerRef: React.RefObject<HTMLElement | null>;
|
containerRef: React.RefObject<HTMLElement | null>;
|
||||||
@@ -22,6 +32,17 @@ interface SelectionPayload {
|
|||||||
rect: DOMRect;
|
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_SIDE_MARGIN_PX = 8;
|
||||||
const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280;
|
const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280;
|
||||||
const BLOCK_TAGS = new Set([
|
const BLOCK_TAGS = new Set([
|
||||||
@@ -186,19 +207,26 @@ const rangeToMarkdown = (range: Range, plainText: string): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerRef }) => {
|
export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerRef }) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
|
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
|
||||||
const [selectedText, setSelectedText] = React.useState('');
|
const [selectedText, setSelectedText] = React.useState('');
|
||||||
const [selectedTextMarkdown, setSelectedTextMarkdown] = 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 [isOpening, setIsOpening] = React.useState(false);
|
||||||
|
const [isAddingToNotes, setIsAddingToNotes] = React.useState(false);
|
||||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||||
const menuWidthRef = React.useRef(DESKTOP_MENU_FALLBACK_WIDTH_PX);
|
const menuWidthRef = React.useRef(DESKTOP_MENU_FALLBACK_WIDTH_PX);
|
||||||
const pendingSelectionRef = React.useRef<SelectionPayload | null>(null);
|
const pendingSelectionRef = React.useRef<SelectionPayload | null>(null);
|
||||||
const openRafRef = React.useRef<number | null>(null);
|
const openRafRef = React.useRef<number | null>(null);
|
||||||
const isMenuVisibleRef = React.useRef(false);
|
const isMenuVisibleRef = React.useRef(false);
|
||||||
const createSession = useSessionStore((state) => state.createSession);
|
const createSession = useSessionUIStore((state) => state.createSession);
|
||||||
const setPendingInputText = useSessionStore((state) => state.setPendingInputText);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
|
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
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(() => {
|
React.useEffect(() => {
|
||||||
isMenuVisibleRef.current = position.show;
|
isMenuVisibleRef.current = position.show;
|
||||||
@@ -323,7 +351,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
|
|
||||||
if (!selection || !container) {
|
if (!selection || !container) {
|
||||||
if (!isDragging) {
|
if (!isDraggingRef.current) {
|
||||||
hideMenu();
|
hideMenu();
|
||||||
}
|
}
|
||||||
return;
|
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
|
// Only show if we have text and the selection is within our container
|
||||||
if (!text) {
|
if (!text) {
|
||||||
if (!isDragging) {
|
if (!isDraggingRef.current) {
|
||||||
hideMenu();
|
hideMenu();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -343,7 +371,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
const range = selection.getRangeAt(0);
|
const range = selection.getRangeAt(0);
|
||||||
|
|
||||||
if (!container.contains(range.commonAncestorContainer)) {
|
if (!container.contains(range.commonAncestorContainer)) {
|
||||||
if (!isDragging) {
|
if (!isDraggingRef.current) {
|
||||||
hideMenu();
|
hideMenu();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -360,10 +388,10 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Only show menu if we're not currently dragging
|
// Only show menu if we're not currently dragging
|
||||||
if (!isDragging) {
|
if (!isDraggingRef.current) {
|
||||||
showMenu();
|
showMenu();
|
||||||
}
|
}
|
||||||
}, [containerRef, hideMenu, showMenu, isDragging]);
|
}, [containerRef, hideMenu, showMenu]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@@ -371,13 +399,13 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
|
|
||||||
// Track when dragging starts
|
// Track when dragging starts
|
||||||
const handleMouseDown = () => {
|
const handleMouseDown = () => {
|
||||||
setIsDragging(true);
|
isDraggingRef.current = true;
|
||||||
hideMenu();
|
hideMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Track when dragging stops
|
// Track when dragging stops
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
setIsDragging(false);
|
isDraggingRef.current = false;
|
||||||
// Check if we have a pending selection to show
|
// Check if we have a pending selection to show
|
||||||
if (pendingSelectionRef.current) {
|
if (pendingSelectionRef.current) {
|
||||||
// Small delay to ensure selection is finalized
|
// Small delay to ensure selection is finalized
|
||||||
@@ -455,6 +483,59 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
window.getSelection()?.removeAllRanges();
|
window.getSelection()?.removeAllRanges();
|
||||||
}, [selectedText, hideMenu]);
|
}, [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;
|
if (!position.show) return null;
|
||||||
|
|
||||||
// Mobile: Show as a bar at the bottom of the screen, above the keyboard
|
// 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
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed left-0 right-0 bottom-0 z-50',
|
'fixed left-3 right-3 bottom-0 z-50 mx-auto max-w-[420px]',
|
||||||
'flex items-center justify-center gap-4',
|
'rounded-2xl border border-[var(--interactive-border)]',
|
||||||
'bg-[var(--surface-elevated)] border-t border-[var(--interactive-border)]',
|
'bg-[var(--surface-elevated)] p-2 shadow-lg',
|
||||||
'px-3 py-2',
|
|
||||||
'safe-area-bottom',
|
'safe-area-bottom',
|
||||||
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||||
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
paddingBottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))',
|
bottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))',
|
||||||
backdropFilter: 'blur(28px)',
|
|
||||||
WebkitBackdropFilter: 'blur(28px)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<div className="grid grid-cols-2 gap-2">
|
||||||
onClick={handleAddToChat}
|
<button
|
||||||
className={cn(
|
onClick={handleAddToChat}
|
||||||
'flex items-center gap-2 px-3 py-2 rounded-lg',
|
className={cn(
|
||||||
'text-sm font-medium',
|
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
|
||||||
'bg-[var(--primary-base)] text-[var(--primary-foreground)]',
|
'text-sm font-medium leading-tight',
|
||||||
'active:opacity-80',
|
'bg-[var(--primary-base)] text-[var(--primary-foreground)]',
|
||||||
'transition-opacity duration-150'
|
'active:opacity-80',
|
||||||
)}
|
'transition-opacity duration-150'
|
||||||
type="button"
|
)}
|
||||||
>
|
title={t('chat.textSelection.title.addToCurrentChat')}
|
||||||
<RiAddLine className="h-5 w-5" />
|
type="button"
|
||||||
<span>Add to chat</span>
|
>
|
||||||
</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(
|
<button
|
||||||
'flex items-center gap-2 px-3 py-2 rounded-lg',
|
onClick={handleCreateNewSession}
|
||||||
'text-sm font-medium',
|
className={cn(
|
||||||
'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]',
|
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
|
||||||
'active:opacity-80',
|
'text-sm font-medium leading-tight',
|
||||||
'transition-opacity duration-150'
|
'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]',
|
||||||
)}
|
'active:opacity-80',
|
||||||
type="button"
|
'transition-opacity duration-150'
|
||||||
>
|
)}
|
||||||
<RiChatNewLine className="h-5 w-5" />
|
title={t('chat.textSelection.title.newSessionWithSelection')}
|
||||||
<span>New session</span>
|
type="button"
|
||||||
</button>
|
>
|
||||||
|
<RiChatNewLine className="h-5 w-5 flex-shrink-0" />
|
||||||
<button
|
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.newSession')}</span>
|
||||||
onClick={handleCopy}
|
</button>
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 px-3 py-2 rounded-lg',
|
<button
|
||||||
'text-sm font-medium',
|
onClick={handleCopy}
|
||||||
'bg-[var(--surface-muted)] text-[var(--surface-foreground)]',
|
className={cn(
|
||||||
'active:opacity-80',
|
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
|
||||||
'transition-opacity duration-150'
|
'text-sm font-medium leading-tight',
|
||||||
)}
|
'bg-[var(--surface-muted)] text-[var(--surface-foreground)]',
|
||||||
type="button"
|
'active:opacity-80',
|
||||||
>
|
'transition-opacity duration-150'
|
||||||
<RiFileCopyLine className="h-5 w-5" />
|
)}
|
||||||
<span>Copy</span>
|
title={t('chat.textSelection.actions.copy')}
|
||||||
</button>
|
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>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
);
|
);
|
||||||
@@ -546,10 +648,6 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||||
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
||||||
)}
|
)}
|
||||||
style={{
|
|
||||||
backdropFilter: 'blur(28px)',
|
|
||||||
WebkitBackdropFilter: 'blur(28px)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddToChat}
|
onClick={handleAddToChat}
|
||||||
@@ -560,11 +658,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
'hover:bg-[var(--interactive-hover)]',
|
'hover:bg-[var(--interactive-hover)]',
|
||||||
'transition-colors duration-150'
|
'transition-colors duration-150'
|
||||||
)}
|
)}
|
||||||
title="Add to current chat"
|
title={t('chat.textSelection.title.addToCurrentChat')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<RiAddLine className="h-4 w-4" />
|
<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>
|
</button>
|
||||||
|
|
||||||
<div className="w-px h-4 bg-[var(--interactive-border)]" />
|
<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)]',
|
'hover:bg-[var(--interactive-hover)]',
|
||||||
'transition-colors duration-150'
|
'transition-colors duration-150'
|
||||||
)}
|
)}
|
||||||
title="Create new session with selection"
|
title={t('chat.textSelection.title.newSessionWithSelection')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<RiChatNewLine className="h-4 w-4" />
|
<RiChatNewLine className="h-4 w-4" />
|
||||||
<span className="whitespace-nowrap">New session</span>
|
<span className="whitespace-nowrap">{t('chat.textSelection.actions.newSession')}</span>
|
||||||
</button>
|
</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>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ import {
|
|||||||
renderWebSearchOutput,
|
renderWebSearchOutput,
|
||||||
formatInputForDisplay,
|
formatInputForDisplay,
|
||||||
parseReadToolOutput,
|
parseReadToolOutput,
|
||||||
|
tryParseJsonOutput,
|
||||||
} from './toolRenderers';
|
} from './toolRenderers';
|
||||||
import type { ToolPopupContent, DiffViewMode } from './types';
|
import type { ToolPopupContent, DiffViewMode } from './types';
|
||||||
import { DiffViewToggle } from './DiffViewToggle';
|
import { DiffViewToggle } from './DiffViewToggle';
|
||||||
import { VirtualizedCodeBlock, type CodeLine } from './parts/VirtualizedCodeBlock';
|
import { VirtualizedCodeBlock, type CodeLine } from './parts/VirtualizedCodeBlock';
|
||||||
|
import { JsonTreeView } from '@/components/ui/JsonTreeView';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
interface ToolOutputDialogProps {
|
interface ToolOutputDialogProps {
|
||||||
popup: ToolPopupContent;
|
popup: ToolPopupContent;
|
||||||
@@ -92,6 +95,10 @@ const MERMAID_DIALOG_HEADER_HEIGHT = 40;
|
|||||||
const MERMAID_ASPECT_RETRY_DELAY_MS = 120;
|
const MERMAID_ASPECT_RETRY_DELAY_MS = 120;
|
||||||
const MERMAID_ASPECT_MAX_RETRIES = 3;
|
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 = {
|
type PierreThemeConfig = {
|
||||||
theme: { light: string; dark: string };
|
theme: { light: string; dark: string };
|
||||||
themeType: 'light' | 'dark';
|
themeType: 'light' | 'dark';
|
||||||
@@ -300,6 +307,7 @@ const ImagePreviewDialog: React.FC<{
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
}> = ({ popup, onOpenChange, isMobile }) => {
|
}> = ({ popup, onOpenChange, isMobile }) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const gallery = React.useMemo(() => {
|
const gallery = React.useMemo(() => {
|
||||||
const baseImage = popup.image;
|
const baseImage = popup.image;
|
||||||
if (!baseImage) return [] as Array<{ url: string; mimeType?: string; filename?: string; size?: number }>;
|
if (!baseImage) return [] as Array<{ url: string; mimeType?: string; filename?: string; size?: number }>;
|
||||||
@@ -418,7 +426,7 @@ const ImagePreviewDialog: React.FC<{
|
|||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 bg-black/25 backdrop-blur-md',
|
'absolute inset-0 bg-black/40',
|
||||||
isTransitioning && 'transition-opacity duration-150 ease-out',
|
isTransitioning && 'transition-opacity duration-150 ease-out',
|
||||||
isVisible ? 'opacity-100' : 'opacity-0'
|
isVisible ? 'opacity-100' : 'opacity-0'
|
||||||
)}
|
)}
|
||||||
@@ -431,8 +439,8 @@ const ImagePreviewDialog: React.FC<{
|
|||||||
type="button"
|
type="button"
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
onClick={showPrevious}
|
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"
|
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="Previous image"
|
aria-label={t('chat.toolOutputDialog.image.previousAria')}
|
||||||
>
|
>
|
||||||
<RiArrowLeftSLine className="h-6 w-6" />
|
<RiArrowLeftSLine className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
@@ -440,8 +448,8 @@ const ImagePreviewDialog: React.FC<{
|
|||||||
type="button"
|
type="button"
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
onClick={showNext}
|
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"
|
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="Next image"
|
aria-label={t('chat.toolOutputDialog.image.nextAria')}
|
||||||
>
|
>
|
||||||
<RiArrowRightSLine className="h-6 w-6" />
|
<RiArrowRightSLine className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
@@ -470,7 +478,7 @@ const ImagePreviewDialog: React.FC<{
|
|||||||
type="button"
|
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"
|
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)}
|
onClick={() => onOpenChange(false)}
|
||||||
aria-label="Close image preview"
|
aria-label={t('chat.toolOutputDialog.image.closeAria')}
|
||||||
>
|
>
|
||||||
<RiCloseLine className="h-4 w-4" />
|
<RiCloseLine className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -630,6 +638,7 @@ const MermaidPreviewDialog: React.FC<{
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
}> = ({ popup, onOpenChange, isMobile }) => {
|
}> = ({ popup, onOpenChange, isMobile }) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const [source, setSource] = React.useState<string>(popup.mermaid?.source || '');
|
const [source, setSource] = React.useState<string>(popup.mermaid?.source || '');
|
||||||
const [status, setStatus] = React.useState<'idle' | 'loading' | 'ready' | 'error'>(popup.mermaid?.source ? 'ready' : 'idle');
|
const [status, setStatus] = React.useState<'idle' | 'loading' | 'ready' | 'error'>(popup.mermaid?.source ? 'ready' : 'idle');
|
||||||
const [errorMessage, setErrorMessage] = React.useState<string>('');
|
const [errorMessage, setErrorMessage] = React.useState<string>('');
|
||||||
@@ -705,7 +714,7 @@ const MermaidPreviewDialog: React.FC<{
|
|||||||
const target = popup.mermaid;
|
const target = popup.mermaid;
|
||||||
if (!target?.url) {
|
if (!target?.url) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMessage('Missing Mermaid source URL.');
|
setErrorMessage(t('chat.toolOutputDialog.mermaid.missingSource'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,9 +780,9 @@ const MermaidPreviewDialog: React.FC<{
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStatus('error');
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (!popup.open || !popup.mermaid) {
|
if (!popup.open || !popup.mermaid) {
|
||||||
@@ -889,7 +898,7 @@ const MermaidPreviewDialog: React.FC<{
|
|||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 bg-black/25 backdrop-blur-md',
|
'absolute inset-0 bg-black/40',
|
||||||
isTransitioning && 'transition-opacity duration-150 ease-out',
|
isTransitioning && 'transition-opacity duration-150 ease-out',
|
||||||
isVisible ? 'opacity-100' : 'opacity-0'
|
isVisible ? 'opacity-100' : 'opacity-0'
|
||||||
)}
|
)}
|
||||||
@@ -916,7 +925,7 @@ const MermaidPreviewDialog: React.FC<{
|
|||||||
type="button"
|
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"
|
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)}
|
onClick={() => onOpenChange(false)}
|
||||||
aria-label="Close diagram preview"
|
aria-label={t('chat.toolOutputDialog.mermaid.closeAria')}
|
||||||
>
|
>
|
||||||
<RiCloseLine className="h-4 w-4" />
|
<RiCloseLine className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -929,14 +938,14 @@ const MermaidPreviewDialog: React.FC<{
|
|||||||
{status === 'loading' && (
|
{status === 'loading' && (
|
||||||
<div className="h-full min-h-28 flex items-center justify-center gap-2 text-muted-foreground typography-meta">
|
<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" />
|
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||||
<span>Loading diagram...</span>
|
<span>{t('chat.toolOutputDialog.mermaid.loading')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === 'error' && (
|
{status === 'error' && (
|
||||||
<div className="rounded-xl border border-border/30 bg-muted/20 p-3 space-y-3">
|
<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)' }}>
|
<p className="typography-markdown" style={{ color: 'var(--status-error)' }}>
|
||||||
{errorMessage || 'Unable to render Mermaid diagram.'}
|
{errorMessage || t('chat.toolOutputDialog.mermaid.renderFailed')}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -949,7 +958,7 @@ const MermaidPreviewDialog: React.FC<{
|
|||||||
color: 'var(--surface-foreground)',
|
color: 'var(--surface-foreground)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Retry
|
{t('chat.toolOutputDialog.mermaid.retry')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -960,13 +969,8 @@ const MermaidPreviewDialog: React.FC<{
|
|||||||
content={mermaidMarkdown}
|
content={mermaidMarkdown}
|
||||||
variant="tool"
|
variant="tool"
|
||||||
allowMermaidWheelZoom
|
allowMermaidWheelZoom
|
||||||
className="streamdown-mermaid-fullscreen h-full [&_[data-streamdown='mermaid-block']_button]:hidden"
|
className="markdown-mermaid-fullscreen h-full [&_[data-markdown='mermaid-block']_button]:hidden"
|
||||||
mermaidControls={{
|
mermaidControls={MERMAID_CONTROLS}
|
||||||
download: false,
|
|
||||||
copy: false,
|
|
||||||
fullscreen: false,
|
|
||||||
panZoom: true,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -981,6 +985,7 @@ const MermaidPreviewDialog: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange, syntaxTheme, isMobile }) => {
|
const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange, syntaxTheme, isMobile }) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>('unified');
|
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>('unified');
|
||||||
const pierreThemeConfig = usePierreThemeConfig();
|
const pierreThemeConfig = usePierreThemeConfig();
|
||||||
|
|
||||||
@@ -1053,7 +1058,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
|||||||
language="bash"
|
language="bash"
|
||||||
PreTag="div"
|
PreTag="div"
|
||||||
customStyle={toolDisplayStyles.getPopupStyles()}
|
customStyle={toolDisplayStyles.getPopupStyles()}
|
||||||
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
|
codeTagProps={DIALOG_CODE_TAG_PROPS}
|
||||||
wrapLongLines
|
wrapLongLines
|
||||||
>
|
>
|
||||||
{getInputValue('command')!}
|
{getInputValue('command')!}
|
||||||
@@ -1110,14 +1115,20 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
|||||||
|
|
||||||
if (tool === 'todowrite' || tool === 'todoread') {
|
if (tool === 'todowrite' || tool === 'todoread') {
|
||||||
return (
|
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
|
<SyntaxHighlighter
|
||||||
style={syntaxTheme}
|
style={syntaxTheme}
|
||||||
language="json"
|
language="json"
|
||||||
PreTag="div"
|
PreTag="div"
|
||||||
wrapLongLines
|
wrapLongLines
|
||||||
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||||
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
|
codeTagProps={DIALOG_CODE_TAG_PROPS}
|
||||||
>
|
>
|
||||||
{popup.content}
|
{popup.content}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
@@ -1172,7 +1183,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
|||||||
PreTag="div"
|
PreTag="div"
|
||||||
wrapLongLines
|
wrapLongLines
|
||||||
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||||
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
|
codeTagProps={DIALOG_CODE_TAG_PROPS}
|
||||||
>
|
>
|
||||||
{popup.content}
|
{popup.content}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
@@ -1184,6 +1195,18 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
|||||||
return <DialogReadContent popup={popup} syntaxTheme={syntaxTheme} pierreThemeConfig={pierreThemeConfig} />;
|
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 (
|
return (
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
style={syntaxTheme}
|
style={syntaxTheme}
|
||||||
@@ -1191,7 +1214,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
|||||||
PreTag="div"
|
PreTag="div"
|
||||||
wrapLongLines
|
wrapLongLines
|
||||||
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||||
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
|
codeTagProps={DIALOG_CODE_TAG_PROPS}
|
||||||
>
|
>
|
||||||
{popup.content}
|
{popup.content}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
@@ -1200,8 +1223,8 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-8 text-muted-foreground typography-ui-header">
|
<div className="p-8 text-muted-foreground typography-ui-header">
|
||||||
<div className="mb-2">Command completed successfully</div>
|
<div className="mb-2">{t('chat.toolOutputDialog.commandCompleted')}</div>
|
||||||
<div className="typography-meta">No output was produced</div>
|
<div className="typography-meta">{t('chat.toolOutputDialog.noOutputProduced')}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -81,15 +81,16 @@ const buildGitHubAttachmentPart = (text: string): Part | null => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const shouldKeepSyntheticUserText = (text: string): boolean => {
|
const shouldKeepSyntheticUserText = (text: string, planModeEnabled: boolean): boolean => {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (trimmed.startsWith('User has requested to enter plan mode')) return true;
|
if (planModeEnabled && trimmed.startsWith('User has requested to enter plan mode')) return true;
|
||||||
if (trimmed.startsWith('The plan at ')) return true;
|
if (planModeEnabled && trimmed.startsWith('The plan at ')) return true;
|
||||||
if (trimmed.startsWith('The following tool was executed by the user')) return true;
|
if (trimmed.startsWith('The following tool was executed by the user')) return true;
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const normalizeUserDisplayParts = (parts: Part[]): Part[] => {
|
export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEnabled?: boolean }): Part[] => {
|
||||||
|
const planModeEnabled = options?.planModeEnabled === true;
|
||||||
return parts
|
return parts
|
||||||
.filter((part) => {
|
.filter((part) => {
|
||||||
const synthetic = (part as { synthetic?: boolean }).synthetic === true;
|
const synthetic = (part as { synthetic?: boolean }).synthetic === true;
|
||||||
@@ -101,7 +102,7 @@ export const normalizeUserDisplayParts = (parts: Part[]): Part[] => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalizedText = text.trimStart();
|
const normalizedText = text.trimStart();
|
||||||
return shouldKeepSyntheticUserText(text)
|
return shouldKeepSyntheticUserText(text, planModeEnabled)
|
||||||
|| normalizedText.startsWith(GITHUB_ISSUE_CONTEXT_PREFIX)
|
|| normalizedText.startsWith(GITHUB_ISSUE_CONTEXT_PREFIX)
|
||||||
|| normalizedText.startsWith(GITHUB_PR_CONTEXT_PREFIX);
|
|| normalizedText.startsWith(GITHUB_PR_CONTEXT_PREFIX);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import type { StreamPhase } from '../types';
|
|||||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||||
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
|
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
|
||||||
import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility';
|
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 } };
|
type PartWithText = Part & { text?: string; content?: string; value?: string; time?: { start?: number; end?: number } };
|
||||||
|
|
||||||
interface AssistantTextPartProps {
|
interface AssistantTextPartProps {
|
||||||
part: Part;
|
part: Part;
|
||||||
|
sessionId?: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
streamPhase: StreamPhase;
|
streamPhase: StreamPhase;
|
||||||
chatRenderMode?: 'sorted' | 'live';
|
chatRenderMode?: 'sorted' | 'live';
|
||||||
@@ -22,6 +24,8 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
|
|||||||
streamPhase,
|
streamPhase,
|
||||||
chatRenderMode = 'live',
|
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 partWithText = part as PartWithText;
|
||||||
const rawText = typeof partWithText.text === 'string' ? partWithText.text : '';
|
const rawText = typeof partWithText.text === 'string' ? partWithText.text : '';
|
||||||
const contentText = typeof partWithText.content === 'string' ? partWithText.content : '';
|
const contentText = typeof partWithText.content === 'string' ? partWithText.content : '';
|
||||||
@@ -33,6 +37,11 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
|
|||||||
const isCooldownPhase = streamPhase === 'cooldown';
|
const isCooldownPhase = streamPhase === 'cooldown';
|
||||||
const isStreaming = chatRenderMode === 'live' && (isStreamingPhase || isCooldownPhase);
|
const isStreaming = chatRenderMode === 'live' && (isStreamingPhase || isCooldownPhase);
|
||||||
|
|
||||||
|
streamPerfCount('ui.assistant_text_part.render');
|
||||||
|
if (isStreaming) {
|
||||||
|
streamPerfCount('ui.assistant_text_part.render.streaming');
|
||||||
|
}
|
||||||
|
|
||||||
const throttledTextContent = useStreamingTextThrottle({
|
const throttledTextContent = useStreamingTextThrottle({
|
||||||
text: textContent,
|
text: textContent,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
@@ -45,32 +54,7 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
|
|||||||
isStreaming,
|
isStreaming,
|
||||||
});
|
});
|
||||||
|
|
||||||
const lastDisplayLengthRef = React.useRef(0);
|
streamPerfObserve('ui.assistant_text_part.display_len', displayTextContent.length);
|
||||||
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]);
|
|
||||||
|
|
||||||
const time = partWithText.time;
|
const time = partWithText.time;
|
||||||
const isFinalized = Boolean(time && typeof time.end !== 'undefined');
|
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`
|
- Text: `AssistantTextPart.tsx`, `UserTextPart.tsx`
|
||||||
- Tools: `ToolPart.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx`
|
- Tools: `ToolPart.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx`
|
||||||
- Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.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`
|
- 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;
|
part: Part;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||||
|
actions?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JustificationBlock: React.FC<JustificationBlockProps> = ({
|
const JustificationBlock: React.FC<JustificationBlockProps> = ({
|
||||||
part,
|
part,
|
||||||
messageId,
|
messageId,
|
||||||
onContentChange,
|
onContentChange,
|
||||||
|
actions,
|
||||||
}) => {
|
}) => {
|
||||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||||
const partWithText = part as PartWithText;
|
const partWithText = part as PartWithText;
|
||||||
@@ -49,6 +51,7 @@ const JustificationBlock: React.FC<JustificationBlockProps> = ({
|
|||||||
blockId={part.id || `${messageId}-justification`}
|
blockId={part.id || `${messageId}-justification`}
|
||||||
time={time}
|
time={time}
|
||||||
showDuration={chatRenderMode !== 'sorted'}
|
showDuration={chatRenderMode !== 'sorted'}
|
||||||
|
actions={actions}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||