Compare commits
123 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
674e7c7561 | ||
|
|
28f61908a7 | ||
|
|
67dde775ac | ||
|
|
311caec278 | ||
|
|
973005e086 | ||
|
|
1939030e9a | ||
|
|
f8d5b2a20a | ||
|
|
a5e33b6cc1 | ||
|
|
846ef90e16 | ||
|
|
d5337a5007 | ||
|
|
a81e0e06a9 | ||
|
|
42f20163f1 | ||
|
|
6e34b8660f | ||
|
|
4f50c1a4b2 | ||
|
|
b0126b26bf | ||
|
|
715753c8d3 | ||
|
|
c2e8e83081 | ||
|
|
9b1a8825d1 | ||
|
|
e09e6ddfd2 | ||
|
|
87a89c4c2c | ||
|
|
0dbd06e5ed | ||
|
|
97180c0fed | ||
|
|
1b2b7c3222 | ||
|
|
17a9b3caaa | ||
|
|
8404e27e07 | ||
|
|
25d3247ce5 | ||
|
|
c47a92f462 | ||
|
|
dc5e9a5d24 | ||
|
|
e63e913849 | ||
|
|
6a1fdf650d | ||
|
|
1f4055a730 | ||
|
|
eb1e300c81 | ||
|
|
c0c0ce4255 | ||
|
|
640f38e706 | ||
|
|
7ea1bd89f6 | ||
|
|
313f47d969 | ||
|
|
d9cf9d461e | ||
|
|
7ab99b5b5e | ||
|
|
d4a79375ca | ||
|
|
c8e9926c20 | ||
|
|
29f6fd936d | ||
|
|
1c4eaf3e6d | ||
|
|
cf672e44d5 | ||
|
|
037e033f35 | ||
|
|
995acd18c9 | ||
|
|
0f2635ab7a | ||
|
|
fcbcbc4c2d | ||
|
|
e57162f7b1 | ||
|
|
9fa07c1b8b | ||
|
|
9b6a662e16 | ||
|
|
9318ea632e | ||
|
|
4effb83cf6 | ||
|
|
52cfd79e43 | ||
|
|
d152fa1d6a | ||
|
|
6434f176e1 | ||
|
|
ef5cd94f4d | ||
|
|
4971a7d91a | ||
|
|
863b62b826 | ||
|
|
e473ce0a66 | ||
|
|
083eed94f1 | ||
|
|
ce32bb071b | ||
|
|
dd6ca0cb54 | ||
|
|
e37cf55367 | ||
|
|
7e5e395e30 | ||
|
|
f69de4ef12 | ||
|
|
cc4ad9d668 | ||
|
|
69bcb494f4 | ||
|
|
593a7e2301 | ||
|
|
7c874a7139 | ||
|
|
baea3e32a9 | ||
|
|
0f9755d7e1 | ||
|
|
37957aacb8 | ||
|
|
a790fa8f30 | ||
|
|
fd6725347b | ||
|
|
5d61977df6 | ||
|
|
51b6d6abec | ||
|
|
0301f904bf | ||
|
|
a6ace1a8d2 | ||
|
|
3bce19d6a0 | ||
|
|
bdc57978ba | ||
|
|
5d0be68695 | ||
|
|
2e623c217f | ||
|
|
ad9e718c31 | ||
|
|
12a79b0981 | ||
|
|
b1dee623b6 | ||
|
|
71a4035a31 | ||
|
|
ac26fc9524 | ||
|
|
03fcd08d34 | ||
|
|
f9ff9bbf3f | ||
|
|
a772b5833c | ||
|
|
f900319d86 | ||
|
|
a477605af1 | ||
|
|
956695665f | ||
|
|
37ca06db35 | ||
|
|
29d9935812 | ||
|
|
492ac17124 | ||
|
|
0eb69850e5 | ||
|
|
a1e01f19da | ||
|
|
151cc1889b | ||
|
|
b5ced3a7fa | ||
|
|
54eddc1f11 | ||
|
|
4bd5bf3bc8 | ||
|
|
c1e780948a | ||
|
|
da2f76c51a | ||
|
|
63483a2d2c | ||
|
|
11a75e0b3c | ||
|
|
16d636592a | ||
|
|
e147711501 | ||
|
|
90d351bca9 | ||
|
|
8f86109caf | ||
|
|
a10afacd6c | ||
|
|
f368a62859 | ||
|
|
8063fac03f | ||
|
|
3c08bc347b | ||
|
|
16b4c469b5 | ||
|
|
95c4072ec1 | ||
|
|
ea04d34bb4 | ||
|
|
0c831856a2 | ||
|
|
2480715f22 | ||
|
|
585c631a86 | ||
|
|
363b8c3482 | ||
|
|
cc5011b98a | ||
|
|
db97ea59ad |
250
.github/workflows/build-desktop.yml
vendored
|
|
@ -130,16 +130,50 @@ jobs:
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install macOS dependencies
|
# Build tools required by vcpkg ports (autotools for gperf, fontconfig, etc.)
|
||||||
|
# These are compile-time only — they do NOT create runtime dylib dependencies.
|
||||||
|
- name: Install build tools
|
||||||
|
run: brew install autoconf autoconf-archive automake libtool pkg-config
|
||||||
|
|
||||||
|
# Static linking via vcpkg — avoids runtime dependency on Homebrew dylibs.
|
||||||
|
# Ref: https://tectonic-typesetting.github.io/book/latest/howto/build-tectonic/
|
||||||
|
# Ref: https://learn.microsoft.com/en-us/vcpkg/users/triplets (arm64-osx defaults to static)
|
||||||
|
- name: Setup vcpkg
|
||||||
run: |
|
run: |
|
||||||
brew install icu4c harfbuzz pkg-config graphite2 freetype fontconfig
|
git clone --depth 1 https://github.com/microsoft/vcpkg $HOME/vcpkg
|
||||||
INCLUDES="-I$(brew --prefix harfbuzz)/include -I$(brew --prefix freetype)/include -I$(brew --prefix graphite2)/include -I$(brew --prefix icu4c)/include"
|
$HOME/vcpkg/bootstrap-vcpkg.sh
|
||||||
{
|
echo "VCPKG_ROOT=$HOME/vcpkg" >> $GITHUB_ENV
|
||||||
echo "PKG_CONFIG_PATH=$(brew --prefix icu4c)/lib/pkgconfig:$(brew --prefix harfbuzz)/lib/pkgconfig:$(brew --prefix graphite2)/lib/pkgconfig:$(brew --prefix freetype)/lib/pkgconfig:$(brew --prefix libpng)/lib/pkgconfig:$(brew --prefix fontconfig)/lib/pkgconfig"
|
|
||||||
echo "CXXFLAGS=-std=c++17 $INCLUDES"
|
- name: Restore vcpkg cache
|
||||||
echo "CFLAGS=$INCLUDES"
|
id: vcpkg-cache
|
||||||
echo "LDFLAGS=-L$(brew --prefix harfbuzz)/lib -L$(brew --prefix freetype)/lib -L$(brew --prefix graphite2)/lib -L$(brew --prefix icu4c)/lib"
|
uses: actions/cache/restore@v4
|
||||||
} >> $GITHUB_ENV
|
with:
|
||||||
|
path: ~/vcpkg/installed
|
||||||
|
key: vcpkg-macos-arm64-v1
|
||||||
|
|
||||||
|
- name: Install macOS dependencies (vcpkg)
|
||||||
|
if: steps.vcpkg-cache.outputs.cache-hit != 'true'
|
||||||
|
env:
|
||||||
|
VCPKG_BINARY_SOURCES: "clear"
|
||||||
|
run: |
|
||||||
|
$HOME/vcpkg/vcpkg install \
|
||||||
|
"harfbuzz[graphite2]:arm64-osx" \
|
||||||
|
fontconfig:arm64-osx \
|
||||||
|
freetype:arm64-osx \
|
||||||
|
icu:arm64-osx
|
||||||
|
|
||||||
|
- name: Save vcpkg cache
|
||||||
|
if: steps.vcpkg-cache.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@v4
|
||||||
|
with:
|
||||||
|
path: ~/vcpkg/installed
|
||||||
|
key: vcpkg-macos-arm64-v1
|
||||||
|
|
||||||
|
- name: Set macOS build environment
|
||||||
|
run: |
|
||||||
|
echo "TECTONIC_DEP_BACKEND=vcpkg" >> $GITHUB_ENV
|
||||||
|
echo "CXXFLAGS=-std=c++17" >> $GITHUB_ENV
|
||||||
|
echo "CFLAGS=" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Import Apple certificate
|
- name: Import Apple certificate
|
||||||
env:
|
env:
|
||||||
|
|
@ -186,7 +220,6 @@ jobs:
|
||||||
ZOTERO_CONSUMER_KEY: ${{ secrets.ZOTERO_CONSUMER_KEY }}
|
ZOTERO_CONSUMER_KEY: ${{ secrets.ZOTERO_CONSUMER_KEY }}
|
||||||
ZOTERO_CONSUMER_SECRET: ${{ secrets.ZOTERO_CONSUMER_SECRET }}
|
ZOTERO_CONSUMER_SECRET: ${{ secrets.ZOTERO_CONSUMER_SECRET }}
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
TECTONIC_DEP_BACKEND: pkg-config
|
|
||||||
run: pnpm --filter @claude-prism/desktop tauri build --target aarch64-apple-darwin
|
run: pnpm --filter @claude-prism/desktop tauri build --target aarch64-apple-darwin
|
||||||
|
|
||||||
- name: Notarize DMG
|
- name: Notarize DMG
|
||||||
|
|
@ -242,6 +275,163 @@ jobs:
|
||||||
sig: ${{ steps.collect.outputs.sig }}
|
sig: ${{ steps.collect.outputs.sig }}
|
||||||
url: ${{ steps.collect.outputs.url }}
|
url: ${{ steps.collect.outputs.url }}
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# macOS (Intel x86_64)
|
||||||
|
# Ref: https://github.com/actions/runner-images/issues/13045
|
||||||
|
# macos-13 retired 2025-12-04, macos-15-intel is the replacement
|
||||||
|
# Intel runners available until Fall 2027
|
||||||
|
# Ref: https://v2.tauri.app/distribute/pipelines/github/
|
||||||
|
# Ref: https://v2.tauri.app/plugin/updater/ (platform key: darwin-x86_64)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
build-macos-intel:
|
||||||
|
runs-on: macos-15-intel
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# Build tools required by vcpkg ports (autotools for gperf, fontconfig, etc.)
|
||||||
|
- name: Install build tools
|
||||||
|
run: brew install autoconf autoconf-archive automake libtool pkg-config
|
||||||
|
|
||||||
|
# Static linking via vcpkg — same approach as Apple Silicon and Windows.
|
||||||
|
- name: Setup vcpkg
|
||||||
|
run: |
|
||||||
|
git clone --depth 1 https://github.com/microsoft/vcpkg $HOME/vcpkg
|
||||||
|
$HOME/vcpkg/bootstrap-vcpkg.sh
|
||||||
|
echo "VCPKG_ROOT=$HOME/vcpkg" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Restore vcpkg cache
|
||||||
|
id: vcpkg-cache
|
||||||
|
uses: actions/cache/restore@v4
|
||||||
|
with:
|
||||||
|
path: ~/vcpkg/installed
|
||||||
|
key: vcpkg-macos-x64-v1
|
||||||
|
|
||||||
|
- name: Install macOS dependencies (vcpkg)
|
||||||
|
if: steps.vcpkg-cache.outputs.cache-hit != 'true'
|
||||||
|
env:
|
||||||
|
VCPKG_BINARY_SOURCES: "clear"
|
||||||
|
run: |
|
||||||
|
$HOME/vcpkg/vcpkg install \
|
||||||
|
"harfbuzz[graphite2]:x64-osx" \
|
||||||
|
fontconfig:x64-osx \
|
||||||
|
freetype:x64-osx \
|
||||||
|
icu:x64-osx
|
||||||
|
|
||||||
|
- name: Save vcpkg cache
|
||||||
|
if: steps.vcpkg-cache.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@v4
|
||||||
|
with:
|
||||||
|
path: ~/vcpkg/installed
|
||||||
|
key: vcpkg-macos-x64-v1
|
||||||
|
|
||||||
|
- name: Set macOS build environment
|
||||||
|
run: |
|
||||||
|
echo "TECTONIC_DEP_BACKEND=vcpkg" >> $GITHUB_ENV
|
||||||
|
echo "CXXFLAGS=-std=c++17" >> $GITHUB_ENV
|
||||||
|
echo "CFLAGS=" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Import Apple certificate
|
||||||
|
env:
|
||||||
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||||
|
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
|
||||||
|
|
||||||
|
# Create temporary keychain
|
||||||
|
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||||
|
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||||
|
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||||
|
|
||||||
|
# Import certificate
|
||||||
|
echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12
|
||||||
|
security import $RUNNER_TEMP/certificate.p12 \
|
||||||
|
-k "$KEYCHAIN_PATH" \
|
||||||
|
-P "$APPLE_CERTIFICATE_PASSWORD" \
|
||||||
|
-T /usr/bin/codesign \
|
||||||
|
-T /usr/bin/security
|
||||||
|
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||||
|
security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: x86_64-apple-darwin
|
||||||
|
|
||||||
|
- uses: swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: apps/desktop/src-tauri -> target
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- run: pnpm install
|
||||||
|
|
||||||
|
- name: Build Tauri app
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
ZOTERO_CONSUMER_KEY: ${{ secrets.ZOTERO_CONSUMER_KEY }}
|
||||||
|
ZOTERO_CONSUMER_SECRET: ${{ secrets.ZOTERO_CONSUMER_SECRET }}
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
|
run: pnpm --filter @claude-prism/desktop tauri build --target x86_64-apple-darwin
|
||||||
|
|
||||||
|
- name: Notarize DMG
|
||||||
|
env:
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
TARGET="x86_64-apple-darwin"
|
||||||
|
BUNDLE="apps/desktop/src-tauri/target/$TARGET/release/bundle"
|
||||||
|
DMG_PATH=$(find "$BUNDLE/dmg" -name '*.dmg' | head -1)
|
||||||
|
APP_PATH="$BUNDLE/macos/ClaudePrism.app"
|
||||||
|
|
||||||
|
if [ -z "$DMG_PATH" ]; then
|
||||||
|
echo "Error: DMG not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Notarizing $DMG_PATH ..."
|
||||||
|
xcrun notarytool submit "$DMG_PATH" \
|
||||||
|
--apple-id "$APPLE_ID" \
|
||||||
|
--team-id "$APPLE_TEAM_ID" \
|
||||||
|
--password "$APPLE_PASSWORD" \
|
||||||
|
--wait --timeout 30m
|
||||||
|
|
||||||
|
echo "==> Stapling..."
|
||||||
|
xcrun stapler staple "$DMG_PATH"
|
||||||
|
xcrun stapler staple "$APP_PATH"
|
||||||
|
|
||||||
|
- name: Collect updater artifacts
|
||||||
|
id: collect
|
||||||
|
run: |
|
||||||
|
BUNDLE="apps/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle"
|
||||||
|
TAR_SIG=$(find "$BUNDLE/macos" -name '*.app.tar.gz.sig' 2>/dev/null | head -1)
|
||||||
|
TAR_GZ=$(find "$BUNDLE/macos" -name '*.app.tar.gz' ! -name '*.sig' 2>/dev/null | head -1)
|
||||||
|
|
||||||
|
if [ -n "$TAR_SIG" ] && [ -n "$TAR_GZ" ]; then
|
||||||
|
echo "sig=$(cat "$TAR_SIG")" >> $GITHUB_OUTPUT
|
||||||
|
echo "url=$(basename "$TAR_GZ")" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload build artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: desktop-macos-intel
|
||||||
|
path: |
|
||||||
|
apps/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle/**/*.dmg
|
||||||
|
apps/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle/**/*.app.tar.gz
|
||||||
|
apps/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle/**/*.sig
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
sig: ${{ steps.collect.outputs.sig }}
|
||||||
|
url: ${{ steps.collect.outputs.url }}
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
# Linux
|
# Linux
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|
@ -257,7 +447,9 @@ jobs:
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y \
|
sudo apt-get install -y \
|
||||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
|
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
|
||||||
libicu-dev libgraphite2-dev libharfbuzz-dev libfreetype-dev libfontconfig-dev
|
autoconf autoconf-archive automake libtool pkg-config \
|
||||||
|
libgraphite2-dev libicu-dev libfontconfig1-dev libharfbuzz-dev \
|
||||||
|
libssl-dev libfreetype-dev libpng-dev zlib1g-dev
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
|
|
@ -280,7 +472,10 @@ jobs:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
ZOTERO_CONSUMER_KEY: ${{ secrets.ZOTERO_CONSUMER_KEY }}
|
ZOTERO_CONSUMER_KEY: ${{ secrets.ZOTERO_CONSUMER_KEY }}
|
||||||
ZOTERO_CONSUMER_SECRET: ${{ secrets.ZOTERO_CONSUMER_SECRET }}
|
ZOTERO_CONSUMER_SECRET: ${{ secrets.ZOTERO_CONSUMER_SECRET }}
|
||||||
|
# Use pkg-config (same as tectonic's own CI) with semi-static linking.
|
||||||
|
# AppImage bundles .so files automatically for portable distribution.
|
||||||
TECTONIC_DEP_BACKEND: pkg-config
|
TECTONIC_DEP_BACKEND: pkg-config
|
||||||
|
TECTONIC_PKGCONFIG_FORCE_SEMI_STATIC: "true"
|
||||||
CXXFLAGS: "-std=c++17"
|
CXXFLAGS: "-std=c++17"
|
||||||
CFLAGS: ""
|
CFLAGS: ""
|
||||||
run: pnpm --filter @claude-prism/desktop tauri build --target x86_64-unknown-linux-gnu
|
run: pnpm --filter @claude-prism/desktop tauri build --target x86_64-unknown-linux-gnu
|
||||||
|
|
@ -316,7 +511,7 @@ jobs:
|
||||||
# Publish: generate latest.json & upload to release
|
# Publish: generate latest.json & upload to release
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
publish:
|
publish:
|
||||||
needs: [build-windows, build-macos, build-linux]
|
needs: [build-windows, build-macos, build-macos-intel, build-linux]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: startsWith(github.ref, 'refs/tags/v') || inputs.release_tag != ''
|
if: startsWith(github.ref, 'refs/tags/v') || inputs.release_tag != ''
|
||||||
|
|
||||||
|
|
@ -344,6 +539,8 @@ jobs:
|
||||||
WIN_URL: ${{ needs.build-windows.outputs.url }}
|
WIN_URL: ${{ needs.build-windows.outputs.url }}
|
||||||
MAC_SIG: ${{ needs.build-macos.outputs.sig }}
|
MAC_SIG: ${{ needs.build-macos.outputs.sig }}
|
||||||
MAC_URL: ${{ needs.build-macos.outputs.url }}
|
MAC_URL: ${{ needs.build-macos.outputs.url }}
|
||||||
|
MAC_INTEL_SIG: ${{ needs.build-macos-intel.outputs.sig }}
|
||||||
|
MAC_INTEL_URL: ${{ needs.build-macos-intel.outputs.url }}
|
||||||
LIN_SIG: ${{ needs.build-linux.outputs.sig }}
|
LIN_SIG: ${{ needs.build-linux.outputs.sig }}
|
||||||
LIN_URL: ${{ needs.build-linux.outputs.url }}
|
LIN_URL: ${{ needs.build-linux.outputs.url }}
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -356,6 +553,8 @@ jobs:
|
||||||
const platforms = {};
|
const platforms = {};
|
||||||
if (process.env.MAC_SIG)
|
if (process.env.MAC_SIG)
|
||||||
platforms['darwin-aarch64'] = { signature: process.env.MAC_SIG, url: BASE + '/ClaudePrism-macOS.app.tar.gz' };
|
platforms['darwin-aarch64'] = { signature: process.env.MAC_SIG, url: BASE + '/ClaudePrism-macOS.app.tar.gz' };
|
||||||
|
if (process.env.MAC_INTEL_SIG)
|
||||||
|
platforms['darwin-x86_64'] = { signature: process.env.MAC_INTEL_SIG, url: BASE + '/ClaudePrism-macOS-Intel.app.tar.gz' };
|
||||||
if (process.env.LIN_SIG)
|
if (process.env.LIN_SIG)
|
||||||
platforms['linux-x86_64'] = { signature: process.env.LIN_SIG, url: BASE + '/ClaudePrism-Linux.AppImage' };
|
platforms['linux-x86_64'] = { signature: process.env.LIN_SIG, url: BASE + '/ClaudePrism-Linux.AppImage' };
|
||||||
if (process.env.WIN_SIG)
|
if (process.env.WIN_SIG)
|
||||||
|
|
@ -387,13 +586,19 @@ jobs:
|
||||||
mkdir -p upload
|
mkdir -p upload
|
||||||
|
|
||||||
# Rename platform artifacts to version-free names
|
# Rename platform artifacts to version-free names
|
||||||
find artifacts -name "*.dmg" | head -1 | xargs -I{} cp {} upload/ClaudePrism-macOS.dmg
|
# macOS Apple Silicon (from desktop-macos artifact)
|
||||||
find artifacts -name "*.app.tar.gz" | head -1 | xargs -I{} cp {} upload/ClaudePrism-macOS.app.tar.gz
|
find artifacts/desktop-macos -name "*.dmg" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-macOS.dmg
|
||||||
find artifacts -name "*-setup.exe" | head -1 | xargs -I{} cp {} upload/ClaudePrism-Windows-setup.exe
|
find artifacts/desktop-macos -name "*.app.tar.gz" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-macOS.app.tar.gz
|
||||||
find artifacts -name "*.msi" | head -1 | xargs -I{} cp {} upload/ClaudePrism-Windows.msi
|
# macOS Intel (from desktop-macos-intel artifact)
|
||||||
find artifacts -name "*.deb" | head -1 | xargs -I{} cp {} upload/ClaudePrism-Linux.deb
|
find artifacts/desktop-macos-intel -name "*.dmg" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-macOS-Intel.dmg
|
||||||
find artifacts -name "*.rpm" | head -1 | xargs -I{} cp {} upload/ClaudePrism-Linux.rpm
|
find artifacts/desktop-macos-intel -name "*.app.tar.gz" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-macOS-Intel.app.tar.gz
|
||||||
find artifacts -name "*.AppImage" | head -1 | xargs -I{} cp {} upload/ClaudePrism-Linux.AppImage
|
# Windows
|
||||||
|
find artifacts/desktop-windows -name "*-setup.exe" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-Windows-setup.exe
|
||||||
|
find artifacts/desktop-windows -name "*.msi" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-Windows.msi
|
||||||
|
# Linux
|
||||||
|
find artifacts/desktop-linux -name "*.deb" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-Linux.deb
|
||||||
|
find artifacts/desktop-linux -name "*.rpm" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-Linux.rpm
|
||||||
|
find artifacts/desktop-linux -name "*.AppImage" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-Linux.AppImage
|
||||||
|
|
||||||
cp latest.json upload/
|
cp latest.json upload/
|
||||||
|
|
||||||
|
|
@ -404,9 +609,4 @@ jobs:
|
||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Publish release
|
# Release stays as draft — publish manually from GitHub Releases UI
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
TAG="${{ steps.tag.outputs.tag }}"
|
|
||||||
gh release edit "$TAG" --draft=false
|
|
||||||
|
|
|
||||||
24
.github/workflows/lint.yml
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
name: Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Biome CI
|
||||||
|
run: pnpm exec biome ci
|
||||||
2
.gitignore
vendored
|
|
@ -31,7 +31,7 @@ tsconfig.tsbuildinfo
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Rust
|
# Rust
|
||||||
Cargo.lock
|
apps/desktop/src-tauri/.cargo/config.toml
|
||||||
|
|
||||||
# Tauri auto-generated schemas
|
# Tauri auto-generated schemas
|
||||||
apps/desktop/src-tauri/gen/schemas/
|
apps/desktop/src-tauri/gen/schemas/
|
||||||
|
|
|
||||||
1
.husky/pre-commit
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
pnpm exec biome check --staged --write --no-errors-on-unmatched
|
||||||
|
|
@ -136,15 +136,25 @@ pnpm lint:fix # auto-fix
|
||||||
|
|
||||||
Rust code follows standard `rustfmt` conventions.
|
Rust code follows standard `rustfmt` conventions.
|
||||||
|
|
||||||
|
### Pre-commit Hook
|
||||||
|
|
||||||
|
A [Husky](https://typicode.github.io/husky/) pre-commit hook runs automatically on every commit. It checks and auto-fixes staged files via `biome check --staged --write`, so lint issues are caught before they reach the repository.
|
||||||
|
|
||||||
|
The hook is set up automatically when you run `pnpm install`.
|
||||||
|
|
||||||
|
### CI
|
||||||
|
|
||||||
|
A GitHub Actions workflow runs `biome ci` on every pull request and push to `main`. PRs that fail lint checks cannot be merged.
|
||||||
|
|
||||||
## Pull Request Process
|
## Pull Request Process
|
||||||
|
|
||||||
1. Fork the repository
|
1. Fork the repository
|
||||||
2. Create a feature branch (`git checkout -b feat/my-feature`)
|
2. Create a feature branch (`git checkout -b feat/my-feature`)
|
||||||
3. Make your changes
|
3. Make your changes
|
||||||
4. Run tests: `pnpm test` (frontend) and `cargo test` (Rust)
|
4. Run tests: `pnpm test` (frontend) and `cargo test` (Rust)
|
||||||
5. Run `pnpm lint` to ensure code quality
|
5. Commit — the pre-commit hook will auto-fix lint issues on staged files
|
||||||
6. Commit with a descriptive message
|
6. Push to your fork and open a PR
|
||||||
7. Push to your fork and open a PR
|
7. CI will verify lint and tests pass
|
||||||
|
|
||||||
### Commit Convention
|
### Commit Convention
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,16 @@
|
||||||
<img src="https://img.shields.io/badge/Website-claudeprism.dev-blue?style=flat-square&logo=googlechrome&logoColor=white" alt="Website" />
|
<img src="https://img.shields.io/badge/Website-claudeprism.dev-blue?style=flat-square&logo=googlechrome&logoColor=white" alt="Website" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS.dmg">
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS.dmg">
|
||||||
<img src="https://img.shields.io/badge/Download-macOS_(Apple_Silicon)-black?style=for-the-badge&logo=apple&logoColor=white" alt="macOS 版をダウンロード" />
|
<img src="https://img.shields.io/badge/Download-macOS_(Apple_Silicon)-black?style=for-the-badge&logo=apple&logoColor=white" alt="macOS (Apple Silicon) をダウンロード" />
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS-Intel.dmg">
|
||||||
|
<img src="https://img.shields.io/badge/Download-macOS_(Intel)-555555?style=for-the-badge&logo=apple&logoColor=white" alt="macOS (Intel) をダウンロード" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Windows-setup.exe">
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Windows-setup.exe">
|
||||||
<img src="https://img.shields.io/badge/Download-Windows-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows 版をダウンロード" />
|
<img src="https://img.shields.io/badge/Download-Windows-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows 版をダウンロード" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Linux.deb">
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Linux.AppImage">
|
||||||
<img src="https://img.shields.io/badge/Download-Linux_(deb)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux 版をダウンロード" />
|
<img src="https://img.shields.io/badge/Download-Linux_(AppImage)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux 版をダウンロード" />
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,16 @@
|
||||||
<img src="https://img.shields.io/badge/Website-claudeprism.dev-blue?style=flat-square&logo=googlechrome&logoColor=white" alt="Website" />
|
<img src="https://img.shields.io/badge/Website-claudeprism.dev-blue?style=flat-square&logo=googlechrome&logoColor=white" alt="Website" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS.dmg">
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS.dmg">
|
||||||
<img src="https://img.shields.io/badge/Download-macOS_(Apple_Silicon)-black?style=for-the-badge&logo=apple&logoColor=white" alt="macOS 다운로드" />
|
<img src="https://img.shields.io/badge/Download-macOS_(Apple_Silicon)-black?style=for-the-badge&logo=apple&logoColor=white" alt="macOS (Apple Silicon) 다운로드" />
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS-Intel.dmg">
|
||||||
|
<img src="https://img.shields.io/badge/Download-macOS_(Intel)-555555?style=for-the-badge&logo=apple&logoColor=white" alt="macOS (Intel) 다운로드" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Windows-setup.exe">
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Windows-setup.exe">
|
||||||
<img src="https://img.shields.io/badge/Download-Windows-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows 다운로드" />
|
<img src="https://img.shields.io/badge/Download-Windows-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows 다운로드" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Linux.deb">
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Linux.AppImage">
|
||||||
<img src="https://img.shields.io/badge/Download-Linux_(deb)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux 다운로드" />
|
<img src="https://img.shields.io/badge/Download-Linux_(AppImage)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux 다운로드" />
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,16 @@
|
||||||
<img src="https://img.shields.io/badge/Website-claudeprism.dev-blue?style=flat-square&logo=googlechrome&logoColor=white" alt="Website" />
|
<img src="https://img.shields.io/badge/Website-claudeprism.dev-blue?style=flat-square&logo=googlechrome&logoColor=white" alt="Website" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS.dmg">
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS.dmg">
|
||||||
<img src="https://img.shields.io/badge/Download-macOS_(Apple_Silicon)-black?style=for-the-badge&logo=apple&logoColor=white" alt="Download for macOS" />
|
<img src="https://img.shields.io/badge/Download-macOS_(Apple_Silicon)-black?style=for-the-badge&logo=apple&logoColor=white" alt="Download for macOS (Apple Silicon)" />
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS-Intel.dmg">
|
||||||
|
<img src="https://img.shields.io/badge/Download-macOS_(Intel)-555555?style=for-the-badge&logo=apple&logoColor=white" alt="Download for macOS (Intel)" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Windows-setup.exe">
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Windows-setup.exe">
|
||||||
<img src="https://img.shields.io/badge/Download-Windows-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Download for Windows" />
|
<img src="https://img.shields.io/badge/Download-Windows-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Download for Windows" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Linux.deb">
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Linux.AppImage">
|
||||||
<img src="https://img.shields.io/badge/Download-Linux_(deb)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download for Linux" />
|
<img src="https://img.shields.io/badge/Download-Linux_(AppImage)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download for Linux" />
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,10 @@
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://claudeprism.delibae.dev?utm_source=github&utm_medium=readme&utm_campaign=launch_v054">官网</a> ·
|
<a href="https://claudeprism.delibae.dev?utm_source=github&utm_medium=readme&utm_campaign=launch_v054">官网</a> ·
|
||||||
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS.dmg">macOS</a> ·
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS.dmg">macOS (Apple Silicon)</a> ·
|
||||||
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS-Intel.dmg">macOS (Intel)</a> ·
|
||||||
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Windows-setup.exe">Windows</a> ·
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Windows-setup.exe">Windows</a> ·
|
||||||
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Linux.deb">Linux</a> ·
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Linux.AppImage">Linux</a> ·
|
||||||
<a href="https://github.com/delibae/claude-prism/releases">所有版本</a>
|
<a href="https://github.com/delibae/claude-prism/releases">所有版本</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@claude-prism/desktop",
|
"name": "@claude-prism/desktop",
|
||||||
"version": "1.0.4",
|
"version": "1.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -26,11 +26,12 @@
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@tauri-apps/api": "^2.5.0",
|
"@replit/codemirror-vim": "^6.3.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.2.2",
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"@tauri-apps/plugin-fs": "^2.3.0",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
"@tauri-apps/plugin-process": "^2.2.2",
|
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||||
"@tauri-apps/plugin-shell": "^2.2.2",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
"@tauri-apps/plugin-updater": "~2.10.0",
|
"@tauri-apps/plugin-updater": "~2.10.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -67,7 +68,8 @@
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^6.3.5",
|
"vite": "^6.4.2",
|
||||||
"vitest": "^3.1.1"
|
"vite-plugin-top-level-await": "^1.6.0",
|
||||||
|
"vitest": "^4.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,9 @@ async function main() {
|
||||||
|
|
||||||
if (fs.existsSync(pdfPath)) {
|
if (fs.existsSync(pdfPath)) {
|
||||||
fs.copyFileSync(pdfPath, path.join(exampleDir, pdfName));
|
fs.copyFileSync(pdfPath, path.join(exampleDir, pdfName));
|
||||||
const sizeKb = Math.round(fs.statSync(path.join(exampleDir, pdfName)).size / 1024);
|
const sizeKb = Math.round(
|
||||||
|
fs.statSync(path.join(exampleDir, pdfName)).size / 1024,
|
||||||
|
);
|
||||||
console.log(`OK (${sizeKb} KB)`);
|
console.log(`OK (${sizeKb} KB)`);
|
||||||
successCount++;
|
successCount++;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -112,7 +114,8 @@ async function main() {
|
||||||
failCount++;
|
failCount++;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message.slice(0, 120) : String(err);
|
const msg =
|
||||||
|
err instanceof Error ? err.message.slice(0, 120) : String(err);
|
||||||
console.log(`FAIL: ${msg}`);
|
console.log(`FAIL: ${msg}`);
|
||||||
failCount++;
|
failCount++;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -125,7 +128,9 @@ async function main() {
|
||||||
console.log(
|
console.log(
|
||||||
"Note: Failed templates may require document classes not installed in your TeX distribution.",
|
"Note: Failed templates may require document classes not installed in your TeX distribution.",
|
||||||
);
|
);
|
||||||
console.log("The gallery will show CSS fallback thumbnails for those templates.");
|
console.log(
|
||||||
|
"The gallery will show CSS fallback thumbnails for those templates.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
# Tectonic build configuration for local macOS development (Homebrew).
|
|
||||||
# ICU is keg-only in Homebrew, so pkg-config can't find it without this path.
|
|
||||||
# HarfBuzz headers need the parent include directory for #include <harfbuzz/hb.h>.
|
|
||||||
# ICU 78+ requires C++17.
|
|
||||||
# On Linux, system packages are in standard paths — these extra paths are harmless.
|
|
||||||
#
|
|
||||||
# CI overrides: force=false means CI env vars (TECTONIC_DEP_BACKEND=vcpkg,
|
|
||||||
# VCPKG_ROOT, CXXFLAGS, CFLAGS) take precedence over these defaults.
|
|
||||||
[env]
|
|
||||||
PKG_CONFIG_PATH = { value = "/opt/homebrew/opt/icu4c/lib/pkgconfig", force = false }
|
|
||||||
CXXFLAGS = { value = "-I/opt/homebrew/include -std=c++17", force = false }
|
|
||||||
CFLAGS = { value = "-I/opt/homebrew/include", force = false }
|
|
||||||
7688
apps/desktop/src-tauri/Cargo.lock
generated
Normal file
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "claude-prism-desktop"
|
name = "claude-prism-desktop"
|
||||||
version = "1.0.4"
|
version = "1.3.0"
|
||||||
description = "AI-powered LaTeX writing workspace"
|
description = "AI-powered LaTeX writing workspace"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ serde_json = "1"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "system-proxy", "socks"] }
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
sha1 = "0.10"
|
sha1 = "0.10"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
|
@ -52,5 +52,9 @@ objc2 = "0.6"
|
||||||
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSRunningApplication"] }
|
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSRunningApplication"] }
|
||||||
objc2-foundation = { version = "0.3", features = ["NSData"] }
|
objc2-foundation = { version = "0.3", features = ["NSData"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
winreg = "0.55"
|
||||||
|
windows-sys = { version = "0.61", features = ["Win32_System_Environment"] }
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,20 @@ fn main() {
|
||||||
println!("cargo:rustc-env={key}={val}");
|
println!("cargo:rustc-env={key}={val}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On Linux, apply a version script to hide statically linked ICU/HarfBuzz/
|
||||||
|
// FreeType/Fontconfig symbols from the dynamic symbol table. This prevents
|
||||||
|
// symbol collisions with the system copies loaded by WebKit2GTK (segfault).
|
||||||
|
// See: https://github.com/delibae/claude-prism/issues/100
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
|
||||||
|
println!(
|
||||||
|
"cargo:rustc-link-arg=-Wl,--version-script={}/symbols.map",
|
||||||
|
manifest_dir
|
||||||
|
);
|
||||||
|
println!("cargo:rerun-if-changed=symbols.map");
|
||||||
|
}
|
||||||
|
|
||||||
tauri_build::build()
|
tauri_build::build()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,28 +15,132 @@
|
||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:webview:allow-create-webview-window",
|
"core:webview:allow-create-webview-window",
|
||||||
|
"core:webview:allow-set-webview-zoom",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
"dialog:allow-save",
|
"dialog:allow-save",
|
||||||
"dialog:allow-message",
|
"dialog:allow-message",
|
||||||
"dialog:allow-ask",
|
"dialog:allow-ask",
|
||||||
"fs:default",
|
"fs:default",
|
||||||
"fs:allow-read",
|
|
||||||
"fs:allow-write",
|
|
||||||
"fs:allow-exists",
|
|
||||||
"fs:allow-mkdir",
|
|
||||||
"fs:allow-remove",
|
|
||||||
"fs:allow-rename",
|
|
||||||
"fs:allow-copy-file",
|
|
||||||
"fs:allow-read-dir",
|
|
||||||
"fs:allow-read-text-file",
|
|
||||||
"fs:allow-write-text-file",
|
|
||||||
"fs:allow-read-file",
|
|
||||||
"fs:allow-write-file",
|
|
||||||
"fs:allow-stat",
|
|
||||||
"fs:allow-watch",
|
|
||||||
{
|
{
|
||||||
"identifier": "fs:scope",
|
"identifier": "fs:allow-read",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-write",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-exists",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-mkdir",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-remove",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-rename",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-copy-file",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-read-dir",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-read-text-file",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-write-text-file",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-read-file",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-write-file",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-stat",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-watch",
|
||||||
"allow": [
|
"allow": [
|
||||||
{ "path": "$HOME/**" },
|
{ "path": "$HOME/**" },
|
||||||
{ "path": "$DOCUMENT/**" },
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,6 @@
|
||||||
{
|
{
|
||||||
"identifier": "desktop-capability",
|
"identifier": "desktop-capability",
|
||||||
"platforms": [
|
"platforms": ["macOS", "windows", "linux"],
|
||||||
"macOS",
|
"windows": ["main"],
|
||||||
"windows",
|
"permissions": ["updater:default"]
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"windows": [
|
|
||||||
"main"
|
|
||||||
],
|
|
||||||
"permissions": [
|
|
||||||
"updater:default"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
614
apps/desktop/src-tauri/src/anthropic_proxy.rs
Normal file
|
|
@ -0,0 +1,614 @@
|
||||||
|
mod messages;
|
||||||
|
mod providers;
|
||||||
|
mod stream;
|
||||||
|
mod tools;
|
||||||
|
mod transformers;
|
||||||
|
|
||||||
|
use self::messages::{anthropic_to_openai_request, openai_to_anthropic_message};
|
||||||
|
use self::providers::apply_provider_request_transforms;
|
||||||
|
use self::stream::{sse_response, stream_openai_sse_to_anthropic};
|
||||||
|
use self::transformers::ProxyTransformerChain;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(crate) struct OpenAiProxyCredential {
|
||||||
|
pub(crate) api_key: String,
|
||||||
|
pub(crate) base_url: String,
|
||||||
|
pub(crate) model: String,
|
||||||
|
pub(crate) transformers: Vec<String>,
|
||||||
|
pub(crate) model_transformers: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn start_openai_anthropic_proxy(
|
||||||
|
credential: OpenAiProxyCredential,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let listener = TcpListener::bind(("127.0.0.1", 0))
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Failed to start local provider proxy: {}", err))?;
|
||||||
|
let addr = listener
|
||||||
|
.local_addr()
|
||||||
|
.map_err(|err| format!("Failed to read local provider proxy address: {}", err))?;
|
||||||
|
let credential = Arc::new(credential);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let Ok((stream, _)) = listener.accept().await else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let credential = Arc::clone(&credential);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(err) = handle_connection(stream, credential).await {
|
||||||
|
eprintln!("[anthropic-proxy] request failed: {}", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(format!("http://{}", addr))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_connection(
|
||||||
|
mut stream: TcpStream,
|
||||||
|
credential: Arc<OpenAiProxyCredential>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let request = read_http_request(&mut stream).await?;
|
||||||
|
let path = request_path_without_query(&request.path);
|
||||||
|
if request.method == "POST" && is_messages_path(path) {
|
||||||
|
match handle_messages_to_stream(&request, &credential, &mut stream).await {
|
||||||
|
Ok(()) => {
|
||||||
|
let _ = stream.shutdown().await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let response = json_response(
|
||||||
|
502,
|
||||||
|
&json!({
|
||||||
|
"type": "error",
|
||||||
|
"error": {
|
||||||
|
"type": "api_error",
|
||||||
|
"message": err,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
stream
|
||||||
|
.write_all(response.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Failed to write proxy error response: {}", err))?;
|
||||||
|
let _ = stream.shutdown().await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = route_request(&request).await;
|
||||||
|
stream
|
||||||
|
.write_all(response.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Failed to write proxy response: {}", err))?;
|
||||||
|
let _ = stream.shutdown().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HttpRequest {
|
||||||
|
method: String,
|
||||||
|
path: String,
|
||||||
|
body: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_http_request(stream: &mut TcpStream) -> Result<HttpRequest, String> {
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
let mut temp = [0_u8; 8192];
|
||||||
|
let header_end = loop {
|
||||||
|
let n = stream
|
||||||
|
.read(&mut temp)
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Failed to read proxy request: {}", err))?;
|
||||||
|
if n == 0 {
|
||||||
|
return Err("Connection closed before HTTP headers were received".to_string());
|
||||||
|
}
|
||||||
|
buffer.extend_from_slice(&temp[..n]);
|
||||||
|
if let Some(index) = find_header_end(&buffer) {
|
||||||
|
break index;
|
||||||
|
}
|
||||||
|
if buffer.len() > 1024 * 1024 {
|
||||||
|
return Err("Proxy request headers are too large".to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let header_text = String::from_utf8_lossy(&buffer[..header_end]);
|
||||||
|
let mut lines = header_text.lines();
|
||||||
|
let request_line = lines
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "Proxy request is missing request line".to_string())?;
|
||||||
|
let mut request_parts = request_line.split_whitespace();
|
||||||
|
let method = request_parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "Proxy request is missing method".to_string())?
|
||||||
|
.to_string();
|
||||||
|
let path = request_parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "Proxy request is missing path".to_string())?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let content_length = lines
|
||||||
|
.filter_map(|line| line.split_once(':'))
|
||||||
|
.find(|(key, _)| key.eq_ignore_ascii_case("content-length"))
|
||||||
|
.and_then(|(_, value)| value.trim().parse::<usize>().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let body_start = header_end + 4;
|
||||||
|
let mut body = buffer.get(body_start..).unwrap_or_default().to_vec();
|
||||||
|
while body.len() < content_length {
|
||||||
|
let n = stream
|
||||||
|
.read(&mut temp)
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Failed to read proxy request body: {}", err))?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
body.extend_from_slice(&temp[..n]);
|
||||||
|
}
|
||||||
|
body.truncate(content_length);
|
||||||
|
|
||||||
|
Ok(HttpRequest { method, path, body })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_header_end(buffer: &[u8]) -> Option<usize> {
|
||||||
|
buffer.windows(4).position(|window| window == b"\r\n\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn route_request(request: &HttpRequest) -> String {
|
||||||
|
let path = request_path_without_query(&request.path);
|
||||||
|
|
||||||
|
if request.method == "GET" && path == "/" {
|
||||||
|
return json_response(
|
||||||
|
200,
|
||||||
|
&json!({ "ok": true, "service": "claude-prism-anthropic-proxy" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.method == "POST" && is_count_tokens_path(path) {
|
||||||
|
return handle_count_tokens(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_response(
|
||||||
|
400,
|
||||||
|
&json!({
|
||||||
|
"type": "error",
|
||||||
|
"error": {
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
"message": format!("Unsupported Anthropic proxy endpoint: {} {}", request.method, request.path),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_path_without_query(path: &str) -> &str {
|
||||||
|
path.split_once('?').map(|(path, _)| path).unwrap_or(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_count_tokens_path(path: &str) -> bool {
|
||||||
|
path.ends_with("/count_tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_messages_path(path: &str) -> bool {
|
||||||
|
path.ends_with("/messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_count_tokens(request: &HttpRequest) -> String {
|
||||||
|
let body = serde_json::from_slice::<Value>(&request.body).unwrap_or(Value::Null);
|
||||||
|
let approx_chars = body.to_string().chars().count();
|
||||||
|
json_response(
|
||||||
|
200,
|
||||||
|
&json!({
|
||||||
|
"input_tokens": (approx_chars / 4).max(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_messages_to_stream(
|
||||||
|
request: &HttpRequest,
|
||||||
|
credential: &OpenAiProxyCredential,
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let anthropic_request: Value = serde_json::from_slice(&request.body)
|
||||||
|
.map_err(|err| format!("Claude Code sent invalid Anthropic JSON: {}", err))?;
|
||||||
|
let wants_stream = anthropic_request
|
||||||
|
.get("stream")
|
||||||
|
.and_then(|value| value.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let transformers = ProxyTransformerChain::for_credential(credential, wants_stream);
|
||||||
|
let mut openai_request =
|
||||||
|
anthropic_to_openai_request(&anthropic_request, credential, &transformers)?;
|
||||||
|
openai_request["stream"] = Value::Bool(wants_stream);
|
||||||
|
apply_provider_request_transforms(
|
||||||
|
&mut openai_request,
|
||||||
|
&anthropic_request,
|
||||||
|
credential,
|
||||||
|
wants_stream,
|
||||||
|
&transformers,
|
||||||
|
);
|
||||||
|
if request_contains_openai_image_parts(&openai_request)
|
||||||
|
&& provider_rejects_openai_image_parts(credential)
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"{} does not accept OpenAI-style image_url message parts. Switch to Claude Code or a vision-capable OpenAI-compatible endpoint for image questions.",
|
||||||
|
credential.model
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(300))
|
||||||
|
.build()
|
||||||
|
.map_err(|err| format!("Failed to create provider client: {}", err))?;
|
||||||
|
let request = client
|
||||||
|
.post(openai_chat_completions_url(&credential.base_url))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(openai_request.to_string());
|
||||||
|
let response = with_optional_bearer_auth(request, &credential.api_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Provider request failed: {}", err))?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let response_text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Failed to read provider error response: {}", err))?;
|
||||||
|
return Err(format!(
|
||||||
|
"Provider returned HTTP {}: {}",
|
||||||
|
status,
|
||||||
|
compact_error_text(&response_text)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if wants_stream {
|
||||||
|
let content_type = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
if content_type.contains("stream") {
|
||||||
|
stream_openai_sse_to_anthropic(stream, response, &anthropic_request, credential).await
|
||||||
|
} else {
|
||||||
|
let response_text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Failed to read provider response: {}", err))?;
|
||||||
|
let openai_response: Value = serde_json::from_str(&response_text)
|
||||||
|
.map_err(|err| format!("Provider returned invalid JSON: {}", err))?;
|
||||||
|
let anthropic_response =
|
||||||
|
openai_to_anthropic_message(&anthropic_request, &openai_response, credential)?;
|
||||||
|
stream
|
||||||
|
.write_all(sse_response(&anthropic_response).as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Failed to write proxy SSE response: {}", err))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let response_text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Failed to read provider response: {}", err))?;
|
||||||
|
let openai_response: Value = serde_json::from_str(&response_text)
|
||||||
|
.map_err(|err| format!("Provider returned invalid JSON: {}", err))?;
|
||||||
|
let anthropic_response =
|
||||||
|
openai_to_anthropic_message(&anthropic_request, &openai_response, credential)?;
|
||||||
|
stream
|
||||||
|
.write_all(json_response(200, &anthropic_response).as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Failed to write proxy JSON response: {}", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn openai_chat_completions_url(base_url: &str) -> String {
|
||||||
|
let clean = base_url.trim_end_matches('/');
|
||||||
|
if clean.ends_with("/chat/completions") {
|
||||||
|
clean.to_string()
|
||||||
|
} else if openai_compatible_base_url_has_chat_root(clean) {
|
||||||
|
format!("{}/chat/completions", clean)
|
||||||
|
} else {
|
||||||
|
format!("{}/v1/chat/completions", clean)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_optional_bearer_auth(
|
||||||
|
request: reqwest::RequestBuilder,
|
||||||
|
api_key: &str,
|
||||||
|
) -> reqwest::RequestBuilder {
|
||||||
|
if api_key.trim().is_empty() {
|
||||||
|
request
|
||||||
|
} else {
|
||||||
|
request.bearer_auth(api_key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_contains_openai_image_parts(value: &Value) -> bool {
|
||||||
|
match value {
|
||||||
|
Value::Array(values) => values.iter().any(request_contains_openai_image_parts),
|
||||||
|
Value::Object(object) => {
|
||||||
|
object.get("type").and_then(Value::as_str) == Some("image_url")
|
||||||
|
|| object.values().any(request_contains_openai_image_parts)
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_rejects_openai_image_parts(credential: &OpenAiProxyCredential) -> bool {
|
||||||
|
let base_url = credential.base_url.to_ascii_lowercase();
|
||||||
|
base_url == "https://api.deepseek.com" || base_url.starts_with("https://api.deepseek.com/")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn openai_compatible_base_url_has_chat_root(base_url: &str) -> bool {
|
||||||
|
let lower = base_url.to_ascii_lowercase();
|
||||||
|
if lower == "https://api.deepseek.com" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = lower
|
||||||
|
.split_once("://")
|
||||||
|
.and_then(|(_, rest)| rest.split_once('/').map(|(_, path)| path))
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim_matches('/');
|
||||||
|
if path.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let segments = path.split('/').collect::<Vec<_>>();
|
||||||
|
let last = segments.last().copied().unwrap_or_default();
|
||||||
|
matches!(last, "v1" | "v2" | "v3" | "v4" | "beta")
|
||||||
|
|| path.ends_with("/openai")
|
||||||
|
|| path.ends_with("compatible-mode/v1")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_response(status: u16, value: &Value) -> String {
|
||||||
|
http_response(
|
||||||
|
status,
|
||||||
|
"application/json; charset=utf-8",
|
||||||
|
&value.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http_response(status: u16, content_type: &str, body: &str) -> String {
|
||||||
|
let reason = match status {
|
||||||
|
200 => "OK",
|
||||||
|
400 => "Bad Request",
|
||||||
|
404 => "Not Found",
|
||||||
|
502 => "Bad Gateway",
|
||||||
|
_ => "Internal Server Error",
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||||
|
status,
|
||||||
|
reason,
|
||||||
|
content_type,
|
||||||
|
body.as_bytes().len(),
|
||||||
|
body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_error_text(text: &str) -> String {
|
||||||
|
let compact = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
if compact.chars().count() <= 1000 {
|
||||||
|
compact
|
||||||
|
} else {
|
||||||
|
format!("{}...", compact.chars().take(1000).collect::<String>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn _assert_local_addr(_: SocketAddr) {}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::transformers::ProxyTransformerChain;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recognizes_anthropic_messages_paths_with_query_strings() {
|
||||||
|
let path = request_path_without_query("/v1/messages?beta=tools");
|
||||||
|
assert_eq!(path, "/v1/messages");
|
||||||
|
assert!(is_messages_path(path));
|
||||||
|
assert!(is_count_tokens_path("/v1/messages/count_tokens"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_openai_image_parts_for_provider_guard() {
|
||||||
|
assert!(request_contains_openai_image_parts(&json!({
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": "what is this?" },
|
||||||
|
{ "type": "image_url", "image_url": { "url": "data:image/png;base64,abc" } }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
})));
|
||||||
|
assert!(!request_contains_openai_image_parts(&json!({
|
||||||
|
"messages": [{ "role": "user", "content": "text only" }]
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_tool_use_and_tool_result_messages() {
|
||||||
|
let credential = OpenAiProxyCredential {
|
||||||
|
api_key: "sk-test".to_string(),
|
||||||
|
base_url: "https://api.example.com/v1".to_string(),
|
||||||
|
model: "qwen-test".to_string(),
|
||||||
|
transformers: Vec::new(),
|
||||||
|
model_transformers: Vec::new(),
|
||||||
|
};
|
||||||
|
let request = json!({
|
||||||
|
"system": "system prompt",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": "toolu_1",
|
||||||
|
"name": "Read",
|
||||||
|
"input": { "file_path": "main.tex" }
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": "toolu_1",
|
||||||
|
"content": "file text"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tools": [{
|
||||||
|
"name": "Read",
|
||||||
|
"description": "Read a file",
|
||||||
|
"input_schema": { "type": "object" }
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let converted = anthropic_to_openai_request(
|
||||||
|
&request,
|
||||||
|
&credential,
|
||||||
|
&ProxyTransformerChain::from_names(&[]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(converted["model"], "qwen-test");
|
||||||
|
assert_eq!(converted["messages"][0]["role"], "system");
|
||||||
|
assert_eq!(
|
||||||
|
converted["messages"][1]["tool_calls"][0]["function"]["name"],
|
||||||
|
"Read"
|
||||||
|
);
|
||||||
|
assert_eq!(converted["messages"][2]["role"], "tool");
|
||||||
|
assert_eq!(converted["tools"][0]["function"]["name"], "Read");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_tool_results_immediately_after_tool_calls() {
|
||||||
|
let credential = OpenAiProxyCredential {
|
||||||
|
api_key: "sk-test".to_string(),
|
||||||
|
base_url: "https://api.example.com/v1".to_string(),
|
||||||
|
model: "qwen-test".to_string(),
|
||||||
|
transformers: Vec::new(),
|
||||||
|
model_transformers: Vec::new(),
|
||||||
|
};
|
||||||
|
let request = json!({
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": "toolu_1",
|
||||||
|
"name": "Read",
|
||||||
|
"input": { "file_path": "main.tex" }
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Now explain it."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": "toolu_1",
|
||||||
|
"content": "file text"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let converted = anthropic_to_openai_request(
|
||||||
|
&request,
|
||||||
|
&credential,
|
||||||
|
&ProxyTransformerChain::from_names(&[]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(converted["messages"][0]["role"], "assistant");
|
||||||
|
assert_eq!(converted["messages"][1]["role"], "tool");
|
||||||
|
assert_eq!(converted["messages"][1]["tool_call_id"], "toolu_1");
|
||||||
|
assert_eq!(converted["messages"][2]["role"], "user");
|
||||||
|
assert_eq!(converted["messages"][2]["content"], "Now explain it.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn synthesizes_missing_tool_results_before_user_messages() {
|
||||||
|
let credential = OpenAiProxyCredential {
|
||||||
|
api_key: "sk-test".to_string(),
|
||||||
|
base_url: "https://api.example.com/v1".to_string(),
|
||||||
|
model: "qwen-test".to_string(),
|
||||||
|
transformers: Vec::new(),
|
||||||
|
model_transformers: Vec::new(),
|
||||||
|
};
|
||||||
|
let request = json!({
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": "toolu_missing",
|
||||||
|
"name": "Read",
|
||||||
|
"input": { "file_path": "main.tex" }
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "continue"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let converted = anthropic_to_openai_request(
|
||||||
|
&request,
|
||||||
|
&credential,
|
||||||
|
&ProxyTransformerChain::from_names(&[]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(converted["messages"][0]["role"], "assistant");
|
||||||
|
assert_eq!(converted["messages"][1]["role"], "tool");
|
||||||
|
assert_eq!(converted["messages"][1]["tool_call_id"], "toolu_missing");
|
||||||
|
assert_eq!(converted["messages"][2]["role"], "user");
|
||||||
|
assert_eq!(converted["messages"][2]["content"], "continue");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_openai_tool_call_to_anthropic_message() {
|
||||||
|
let credential = OpenAiProxyCredential {
|
||||||
|
api_key: "sk-test".to_string(),
|
||||||
|
base_url: "https://api.example.com/v1".to_string(),
|
||||||
|
model: "deepseek-test".to_string(),
|
||||||
|
transformers: Vec::new(),
|
||||||
|
model_transformers: Vec::new(),
|
||||||
|
};
|
||||||
|
let request = json!({ "model": "claude-sonnet-4" });
|
||||||
|
let response = json!({
|
||||||
|
"id": "chatcmpl_1",
|
||||||
|
"choices": [{
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": null,
|
||||||
|
"tool_calls": [{
|
||||||
|
"id": "call_1",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "Grep",
|
||||||
|
"arguments": "{\"pattern\":\"FastVID\"}"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"finish_reason": "tool_calls"
|
||||||
|
}],
|
||||||
|
"usage": { "prompt_tokens": 10, "completion_tokens": 3 }
|
||||||
|
});
|
||||||
|
|
||||||
|
let converted = openai_to_anthropic_message(&request, &response, &credential).unwrap();
|
||||||
|
assert_eq!(converted["stop_reason"], "tool_use");
|
||||||
|
assert_eq!(converted["content"][0]["type"], "tool_use");
|
||||||
|
assert_eq!(converted["content"][0]["input"]["pattern"], "FastVID");
|
||||||
|
}
|
||||||
|
}
|
||||||
899
apps/desktop/src-tauri/src/anthropic_proxy/messages.rs
Normal file
|
|
@ -0,0 +1,899 @@
|
||||||
|
use super::tools::{normalized_tool_call_id, repair_tool_arguments, repaired_tool_arguments_value};
|
||||||
|
use super::transformers::ProxyTransformerChain;
|
||||||
|
use super::OpenAiProxyCredential;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
const EXIT_TOOL_NAME: &str = "ExitTool";
|
||||||
|
|
||||||
|
pub(super) fn anthropic_to_openai_request(
|
||||||
|
request: &Value,
|
||||||
|
credential: &OpenAiProxyCredential,
|
||||||
|
transformers: &ProxyTransformerChain,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
if let Some(system) = request.get("system").and_then(flatten_anthropic_content) {
|
||||||
|
if !system.trim().is_empty() {
|
||||||
|
messages.push(json!({ "role": "system", "content": system }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for message in request
|
||||||
|
.get("messages")
|
||||||
|
.and_then(|value| value.as_array())
|
||||||
|
.ok_or_else(|| "Anthropic request is missing messages[]".to_string())?
|
||||||
|
{
|
||||||
|
append_openai_messages_for_anthropic_message(&mut messages, message);
|
||||||
|
}
|
||||||
|
let messages = normalize_openai_tool_message_pairs(messages);
|
||||||
|
|
||||||
|
let mut body = json!({
|
||||||
|
"model": credential.model,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": false,
|
||||||
|
});
|
||||||
|
copy_number_field(request, &mut body, "temperature");
|
||||||
|
copy_number_field(request, &mut body, "top_p");
|
||||||
|
copy_number_field(request, &mut body, "top_k");
|
||||||
|
copy_number_field(request, &mut body, "max_tokens");
|
||||||
|
if let Some(stop) = request.get("stop_sequences") {
|
||||||
|
body["stop"] = stop.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(tools) = request.get("tools").and_then(|value| value.as_array()) {
|
||||||
|
let converted = tools
|
||||||
|
.iter()
|
||||||
|
.filter_map(anthropic_tool_to_openai_tool)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if !converted.is_empty() {
|
||||||
|
let tool_choice = if transformers.has_tooluse() {
|
||||||
|
Value::String("required".to_string())
|
||||||
|
} else {
|
||||||
|
openai_tool_choice(request.get("tool_choice"))
|
||||||
|
};
|
||||||
|
let mut converted = converted;
|
||||||
|
if tool_choice == Value::String("required".to_string()) {
|
||||||
|
append_exit_tool(&mut converted);
|
||||||
|
append_exit_tool_reminder(&mut body);
|
||||||
|
}
|
||||||
|
body["tools"] = Value::Array(converted);
|
||||||
|
body["tool_choice"] = tool_choice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn openai_to_anthropic_message(
|
||||||
|
anthropic_request: &Value,
|
||||||
|
openai_response: &Value,
|
||||||
|
credential: &OpenAiProxyCredential,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let message = openai_response
|
||||||
|
.pointer("/choices/0/message")
|
||||||
|
.ok_or_else(|| "Provider response is missing choices[0].message".to_string())?;
|
||||||
|
let mut content = Vec::new();
|
||||||
|
|
||||||
|
if let Some(reasoning) = openai_message_thinking(message) {
|
||||||
|
content.push(json!({ "type": "thinking", "thinking": reasoning }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(text) = openai_message_text(message).filter(|value| !value.trim().is_empty()) {
|
||||||
|
content.push(json!({ "type": "text", "text": text }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(tool_calls) = message.get("tool_calls").and_then(|value| value.as_array()) {
|
||||||
|
for call in tool_calls {
|
||||||
|
let function = call.get("function").unwrap_or(&Value::Null);
|
||||||
|
let name = function
|
||||||
|
.get("name")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let arguments = function
|
||||||
|
.get("arguments")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("{}");
|
||||||
|
if name == EXIT_TOOL_NAME {
|
||||||
|
if let Some(response) = exit_tool_response(arguments) {
|
||||||
|
content.push(json!({ "type": "text", "text": response }));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let input = repaired_tool_arguments_value(arguments);
|
||||||
|
let id = normalized_tool_call_id(call.get("id").and_then(|value| value.as_str()));
|
||||||
|
content.push(json!({
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"input": input,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if content.is_empty() {
|
||||||
|
content.push(json!({ "type": "text", "text": "" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
let finish_reason = openai_response
|
||||||
|
.pointer("/choices/0/finish_reason")
|
||||||
|
.and_then(|value| value.as_str());
|
||||||
|
let stop_reason = if content
|
||||||
|
.iter()
|
||||||
|
.any(|block| block.get("type").and_then(|value| value.as_str()) == Some("tool_use"))
|
||||||
|
{
|
||||||
|
"tool_use"
|
||||||
|
} else {
|
||||||
|
match finish_reason {
|
||||||
|
Some("length") => "max_tokens",
|
||||||
|
Some("tool_calls") if !contains_only_exit_tool(message) => "tool_use",
|
||||||
|
_ => "end_turn",
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let usage = openai_response.get("usage").unwrap_or(&Value::Null);
|
||||||
|
Ok(json!({
|
||||||
|
"id": openai_response
|
||||||
|
.get("id")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| format!("msg_{}", uuid::Uuid::new_v4().simple())),
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": anthropic_request
|
||||||
|
.get("model")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or(&credential.model),
|
||||||
|
"content": content,
|
||||||
|
"stop_reason": stop_reason,
|
||||||
|
"stop_sequence": Value::Null,
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": usage_token(usage, &["prompt_tokens", "input_tokens", "prompt_token_count"]),
|
||||||
|
"output_tokens": usage_token(usage, &["completion_tokens", "output_tokens", "completion_token_count"]),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_openai_messages_for_anthropic_message(messages: &mut Vec<Value>, message: &Value) {
|
||||||
|
let role = message
|
||||||
|
.get("role")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("user");
|
||||||
|
let content = message.get("content").unwrap_or(&Value::Null);
|
||||||
|
|
||||||
|
if role == "assistant" {
|
||||||
|
let (text, tool_calls, thinking) = assistant_content_to_openai(content);
|
||||||
|
let mut openai_message = json!({
|
||||||
|
"role": "assistant",
|
||||||
|
"content": if text.trim().is_empty() { Value::Null } else { Value::String(text) },
|
||||||
|
});
|
||||||
|
if !tool_calls.is_empty() {
|
||||||
|
openai_message["tool_calls"] = Value::Array(tool_calls);
|
||||||
|
}
|
||||||
|
if let Some(thinking) = thinking {
|
||||||
|
openai_message["thinking"] = thinking;
|
||||||
|
}
|
||||||
|
messages.push(openai_message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(blocks) = content.as_array() {
|
||||||
|
let content_parts = user_content_blocks_to_openai_parts(blocks);
|
||||||
|
if !content_parts.is_empty() {
|
||||||
|
let content = if content_parts.len() == 1
|
||||||
|
&& content_parts[0]
|
||||||
|
.get("type")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
== Some("text")
|
||||||
|
{
|
||||||
|
content_parts[0]
|
||||||
|
.get("text")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!(""))
|
||||||
|
} else {
|
||||||
|
Value::Array(content_parts)
|
||||||
|
};
|
||||||
|
messages.push(json!({ "role": role, "content": content }));
|
||||||
|
}
|
||||||
|
|
||||||
|
for block in blocks {
|
||||||
|
if block.get("type").and_then(|value| value.as_str()) != Some("tool_result") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let tool_call_id = block
|
||||||
|
.get("tool_use_id")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("toolu_unknown");
|
||||||
|
let (content, image_parts) =
|
||||||
|
tool_result_content_to_openai(block.get("content").unwrap_or(&Value::Null));
|
||||||
|
let content = if content.trim().is_empty() && !image_parts.is_empty() {
|
||||||
|
"Tool returned image content.".to_string()
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
};
|
||||||
|
messages.push(json!({
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tool_call_id,
|
||||||
|
"content": content,
|
||||||
|
}));
|
||||||
|
if !image_parts.is_empty() {
|
||||||
|
let mut content_parts = vec![json!({
|
||||||
|
"type": "text",
|
||||||
|
"text": format!(
|
||||||
|
"Tool result for {} included image content. Use the attached image when answering.",
|
||||||
|
tool_call_id
|
||||||
|
),
|
||||||
|
})];
|
||||||
|
content_parts.extend(image_parts);
|
||||||
|
messages.push(json!({
|
||||||
|
"role": "user",
|
||||||
|
"content": content_parts,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = content
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| content.to_string());
|
||||||
|
messages.push(json!({ "role": role, "content": text }));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_content_blocks_to_openai_parts(blocks: &[Value]) -> Vec<Value> {
|
||||||
|
blocks
|
||||||
|
.iter()
|
||||||
|
.filter_map(
|
||||||
|
|block| match block.get("type").and_then(|value| value.as_str()) {
|
||||||
|
Some("text") => block
|
||||||
|
.get("text")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(|text| json!({ "type": "text", "text": text })),
|
||||||
|
Some("image") => anthropic_image_block_to_openai_part(block),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn anthropic_image_block_to_openai_part(block: &Value) -> Option<Value> {
|
||||||
|
let source = block.get("source")?;
|
||||||
|
let url = match source.get("type").and_then(|value| value.as_str()) {
|
||||||
|
Some("base64") => {
|
||||||
|
let media_type = source
|
||||||
|
.get("media_type")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("image/png");
|
||||||
|
let data = source.get("data").and_then(|value| value.as_str())?;
|
||||||
|
format!("data:{};base64,{}", media_type, data)
|
||||||
|
}
|
||||||
|
Some("url") => source
|
||||||
|
.get("url")
|
||||||
|
.and_then(|value| value.as_str())?
|
||||||
|
.to_string(),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(json!({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": url,
|
||||||
|
"detail": "high",
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_openai_tool_message_pairs(messages: Vec<Value>) -> Vec<Value> {
|
||||||
|
let mut normalized = Vec::with_capacity(messages.len());
|
||||||
|
let mut consumed = vec![false; messages.len()];
|
||||||
|
|
||||||
|
for index in 0..messages.len() {
|
||||||
|
if consumed[index] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = &messages[index];
|
||||||
|
let tool_call_ids = openai_assistant_tool_call_ids(message);
|
||||||
|
if !tool_call_ids.is_empty() {
|
||||||
|
consumed[index] = true;
|
||||||
|
normalized.push(message.clone());
|
||||||
|
|
||||||
|
for tool_call_id in tool_call_ids {
|
||||||
|
if let Some(tool_index) =
|
||||||
|
find_following_tool_message(&messages, &consumed, index + 1, &tool_call_id)
|
||||||
|
{
|
||||||
|
consumed[tool_index] = true;
|
||||||
|
normalized.push(messages[tool_index].clone());
|
||||||
|
} else {
|
||||||
|
normalized.push(json!({
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tool_call_id,
|
||||||
|
"content": "Tool result unavailable in the prior Claude Code transcript.",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
consumed[index] = true;
|
||||||
|
if openai_message_role(message) == Some("tool") {
|
||||||
|
normalized.push(orphan_tool_message_to_user_message(message));
|
||||||
|
} else {
|
||||||
|
normalized.push(message.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
fn openai_assistant_tool_call_ids(message: &Value) -> Vec<String> {
|
||||||
|
if openai_message_role(message) != Some("assistant") {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
message
|
||||||
|
.get("tool_calls")
|
||||||
|
.and_then(|value| value.as_array())
|
||||||
|
.map(|tool_calls| {
|
||||||
|
tool_calls
|
||||||
|
.iter()
|
||||||
|
.filter_map(|tool_call| tool_call.get("id").and_then(|value| value.as_str()))
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_following_tool_message(
|
||||||
|
messages: &[Value],
|
||||||
|
consumed: &[bool],
|
||||||
|
start: usize,
|
||||||
|
tool_call_id: &str,
|
||||||
|
) -> Option<usize> {
|
||||||
|
for index in start..messages.len() {
|
||||||
|
if consumed[index] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let message = &messages[index];
|
||||||
|
if openai_message_role(message) == Some("assistant") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if openai_tool_message_id(message) == Some(tool_call_id) {
|
||||||
|
return Some(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn orphan_tool_message_to_user_message(message: &Value) -> Value {
|
||||||
|
let tool_call_id = openai_tool_message_id(message).unwrap_or("unknown");
|
||||||
|
let content = message
|
||||||
|
.get("content")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
message
|
||||||
|
.get("content")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Null)
|
||||||
|
.to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"role": "user",
|
||||||
|
"content": format!("Tool result for {}:\n{}", tool_call_id, content),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn openai_message_role(message: &Value) -> Option<&str> {
|
||||||
|
message.get("role").and_then(|value| value.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn openai_tool_message_id(message: &Value) -> Option<&str> {
|
||||||
|
if openai_message_role(message) != Some("tool") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
message.get("tool_call_id").and_then(|value| value.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flatten_anthropic_content(value: &Value) -> Option<String> {
|
||||||
|
if let Some(text) = value.as_str() {
|
||||||
|
return Some(text.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
value.as_array().map(|blocks| {
|
||||||
|
blocks
|
||||||
|
.iter()
|
||||||
|
.filter_map(|block| {
|
||||||
|
block
|
||||||
|
.get("text")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.or_else(|| block.get("content").and_then(|value| value.as_str()))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assistant_content_to_openai(content: &Value) -> (String, Vec<Value>, Option<Value>) {
|
||||||
|
let Some(blocks) = content.as_array() else {
|
||||||
|
return (
|
||||||
|
content
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| content.to_string()),
|
||||||
|
Vec::new(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut text = Vec::new();
|
||||||
|
let mut tool_calls = Vec::new();
|
||||||
|
let mut thinking = None;
|
||||||
|
for block in blocks {
|
||||||
|
match block.get("type").and_then(|value| value.as_str()) {
|
||||||
|
Some("text") => {
|
||||||
|
if let Some(value) = block.get("text").and_then(|value| value.as_str()) {
|
||||||
|
text.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some("tool_use") => {
|
||||||
|
let id = block
|
||||||
|
.get("id")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("toolu_unknown");
|
||||||
|
let name = block
|
||||||
|
.get("name")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let input = block.get("input").cloned().unwrap_or_else(|| json!({}));
|
||||||
|
tool_calls.push(json!({
|
||||||
|
"id": id,
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": name,
|
||||||
|
"arguments": input.to_string(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Some("thinking") => {
|
||||||
|
if let Some(value) = block.get("thinking").and_then(|value| value.as_str()) {
|
||||||
|
let mut thinking_value = json!({ "content": value });
|
||||||
|
if let Some(signature) = block.get("signature").and_then(|value| value.as_str())
|
||||||
|
{
|
||||||
|
thinking_value["signature"] = Value::String(signature.to_string());
|
||||||
|
}
|
||||||
|
thinking = Some(thinking_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(text.join("\n\n"), tool_calls, thinking)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_result_content_to_openai(content: &Value) -> (String, Vec<Value>) {
|
||||||
|
if let Some(text) = content.as_str() {
|
||||||
|
return (text.to_string(), Vec::new());
|
||||||
|
}
|
||||||
|
if let Some(blocks) = content.as_array() {
|
||||||
|
let mut text = Vec::new();
|
||||||
|
let mut image_parts = Vec::new();
|
||||||
|
for block in blocks {
|
||||||
|
if let Some(value) = block
|
||||||
|
.get("text")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.or_else(|| block.get("content").and_then(|value| value.as_str()))
|
||||||
|
{
|
||||||
|
text.push(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if block.get("type").and_then(|value| value.as_str()) == Some("image") {
|
||||||
|
if let Some(part) = anthropic_image_block_to_openai_part(block) {
|
||||||
|
image_parts.push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (text.join("\n\n"), image_parts);
|
||||||
|
}
|
||||||
|
(content.to_string(), Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn anthropic_tool_to_openai_tool(tool: &Value) -> Option<Value> {
|
||||||
|
let name = tool.get("name")?.as_str()?;
|
||||||
|
let description = tool
|
||||||
|
.get("description")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let parameters = tool
|
||||||
|
.get("input_schema")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!({ "type": "object", "properties": {} }));
|
||||||
|
|
||||||
|
Some(json!({
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"parameters": parameters,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_exit_tool(tools: &mut Vec<Value>) {
|
||||||
|
if tools.iter().any(|tool| {
|
||||||
|
tool.pointer("/function/name")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
== Some(EXIT_TOOL_NAME)
|
||||||
|
}) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tools.push(json!({
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": EXIT_TOOL_NAME,
|
||||||
|
"description": "Use this when tool mode is active and no remaining tool call is needed. This is the valid way to exit tool mode with a final answer.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"response": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Final response to show the user exactly as written."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["response"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_exit_tool_reminder(body: &mut Value) {
|
||||||
|
let Some(messages) = body
|
||||||
|
.get_mut("messages")
|
||||||
|
.and_then(|value| value.as_array_mut())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
messages.push(json!({
|
||||||
|
"role": "system",
|
||||||
|
"content": "<system-reminder>Tool mode is active. The user expects you to proactively execute the most suitable tool to help complete the task. Before invoking a tool, carefully evaluate whether it matches the current task. If no available tool is appropriate, or the task is complete, call ExitTool with the final response instead of inventing another tool call.</system-reminder>",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn openai_tool_choice(choice: Option<&Value>) -> Value {
|
||||||
|
let Some(choice) = choice else {
|
||||||
|
return Value::String("auto".to_string());
|
||||||
|
};
|
||||||
|
match choice.get("type").and_then(|value| value.as_str()) {
|
||||||
|
Some("auto") => Value::String("auto".to_string()),
|
||||||
|
Some("any") => Value::String("required".to_string()),
|
||||||
|
Some("tool") => {
|
||||||
|
let name = choice
|
||||||
|
.get("name")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
|
json!({
|
||||||
|
"type": "function",
|
||||||
|
"function": { "name": name },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => Value::String("auto".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_number_field(source: &Value, target: &mut Value, key: &str) {
|
||||||
|
if let Some(value) = source.get(key).filter(|value| value.is_number()) {
|
||||||
|
target[key] = value.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn openai_message_text(message: &Value) -> Option<String> {
|
||||||
|
let content = message.get("content")?;
|
||||||
|
if let Some(text) = content.as_str() {
|
||||||
|
return Some(text.to_string());
|
||||||
|
}
|
||||||
|
content.as_array().map(|parts| {
|
||||||
|
parts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|part| {
|
||||||
|
part.get("text")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.or_else(|| {
|
||||||
|
if part.get("type").and_then(|value| value.as_str()) == Some("text") {
|
||||||
|
part.get("content").and_then(|value| value.as_str())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn openai_message_thinking(message: &Value) -> Option<String> {
|
||||||
|
message
|
||||||
|
.get("reasoning_content")
|
||||||
|
.or_else(|| message.get("reasoning"))
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.or_else(|| {
|
||||||
|
message
|
||||||
|
.get("thinking")
|
||||||
|
.and_then(|value| {
|
||||||
|
value
|
||||||
|
.get("content")
|
||||||
|
.and_then(|content| content.as_str())
|
||||||
|
.or_else(|| value.as_str())
|
||||||
|
})
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exit_tool_response(arguments: &str) -> Option<String> {
|
||||||
|
let repaired = repair_tool_arguments(arguments);
|
||||||
|
serde_json::from_str::<Value>(&repaired)
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| {
|
||||||
|
value
|
||||||
|
.get("response")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains_only_exit_tool(message: &Value) -> bool {
|
||||||
|
let Some(tool_calls) = message.get("tool_calls").and_then(|value| value.as_array()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
!tool_calls.is_empty()
|
||||||
|
&& tool_calls.iter().all(|call| {
|
||||||
|
call.pointer("/function/name")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
== Some(EXIT_TOOL_NAME)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage_token(usage: &Value, keys: &[&str]) -> u64 {
|
||||||
|
keys.iter()
|
||||||
|
.find_map(|key| usage.get(*key).and_then(|value| value.as_u64()))
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn credential() -> OpenAiProxyCredential {
|
||||||
|
OpenAiProxyCredential {
|
||||||
|
api_key: "sk-test".to_string(),
|
||||||
|
base_url: "https://api.example.com/v1".to_string(),
|
||||||
|
model: "qwen-test".to_string(),
|
||||||
|
transformers: Vec::new(),
|
||||||
|
model_transformers: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transformers(names: &[&str]) -> ProxyTransformerChain {
|
||||||
|
ProxyTransformerChain::from_names(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_anthropic_image_blocks_as_openai_image_url_parts() {
|
||||||
|
let request = json!({
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": "what is this?" },
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"source": {
|
||||||
|
"type": "base64",
|
||||||
|
"media_type": "image/png",
|
||||||
|
"data": "abcd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let converted =
|
||||||
|
anthropic_to_openai_request(&request, &credential(), &transformers(&[])).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(converted["messages"][0]["content"][0]["type"], "text");
|
||||||
|
assert_eq!(converted["messages"][0]["content"][1]["type"], "image_url");
|
||||||
|
assert_eq!(
|
||||||
|
converted["messages"][0]["content"][1]["image_url"]["url"],
|
||||||
|
"data:image/png;base64,abcd"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
converted["messages"][0]["content"][1]["image_url"]["detail"],
|
||||||
|
"high"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_tool_result_images_as_follow_up_user_image_parts() {
|
||||||
|
let request = json!({
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": "toolu_read_image",
|
||||||
|
"name": "Read",
|
||||||
|
"input": { "file_path": "attachments/figure.png" }
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": "toolu_read_image",
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": "Image read successfully." },
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"source": {
|
||||||
|
"type": "base64",
|
||||||
|
"media_type": "image/png",
|
||||||
|
"data": "abcd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let converted =
|
||||||
|
anthropic_to_openai_request(&request, &credential(), &transformers(&[])).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(converted["messages"][0]["role"], "assistant");
|
||||||
|
assert_eq!(converted["messages"][1]["role"], "tool");
|
||||||
|
assert_eq!(converted["messages"][1]["tool_call_id"], "toolu_read_image");
|
||||||
|
assert_eq!(
|
||||||
|
converted["messages"][1]["content"],
|
||||||
|
"Image read successfully."
|
||||||
|
);
|
||||||
|
assert_eq!(converted["messages"][2]["role"], "user");
|
||||||
|
assert_eq!(converted["messages"][2]["content"][0]["type"], "text");
|
||||||
|
assert_eq!(converted["messages"][2]["content"][1]["type"], "image_url");
|
||||||
|
assert_eq!(
|
||||||
|
converted["messages"][2]["content"][1]["image_url"]["url"],
|
||||||
|
"data:image/png;base64,abcd"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
converted["messages"][2]["content"][1]["image_url"]["detail"],
|
||||||
|
"high"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_assistant_thinking_for_provider_context() {
|
||||||
|
let request = json!({
|
||||||
|
"messages": [{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "thinking",
|
||||||
|
"thinking": "I inspected the files.",
|
||||||
|
"signature": "sig_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Done."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let converted =
|
||||||
|
anthropic_to_openai_request(&request, &credential(), &transformers(&[])).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
converted["messages"][0]["thinking"]["content"],
|
||||||
|
"I inspected the files."
|
||||||
|
);
|
||||||
|
assert_eq!(converted["messages"][0]["thinking"]["signature"], "sig_1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adds_exit_tool_when_tool_choice_requires_a_tool() {
|
||||||
|
let request = json!({
|
||||||
|
"messages": [{ "role": "user", "content": "finish" }],
|
||||||
|
"tool_choice": { "type": "any" },
|
||||||
|
"tools": [{
|
||||||
|
"name": "Read",
|
||||||
|
"description": "Read a file",
|
||||||
|
"input_schema": { "type": "object" }
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let converted =
|
||||||
|
anthropic_to_openai_request(&request, &credential(), &transformers(&[])).unwrap();
|
||||||
|
let tool_names = converted["tools"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|tool| {
|
||||||
|
tool.pointer("/function/name")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert!(tool_names.contains(&"Read"));
|
||||||
|
assert!(tool_names.contains(&EXIT_TOOL_NAME));
|
||||||
|
assert_eq!(converted["tool_choice"], "required");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tooluse_transformer_forces_exit_tool_like_ccr() {
|
||||||
|
let request = json!({
|
||||||
|
"messages": [{ "role": "user", "content": "finish" }],
|
||||||
|
"tools": [{
|
||||||
|
"name": "Read",
|
||||||
|
"description": "Read a file",
|
||||||
|
"input_schema": { "type": "object" }
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let converted =
|
||||||
|
anthropic_to_openai_request(&request, &credential(), &transformers(&["tooluse"]))
|
||||||
|
.unwrap();
|
||||||
|
let tool_names = converted["tools"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|tool| {
|
||||||
|
tool.pointer("/function/name")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert_eq!(converted["tool_choice"], "required");
|
||||||
|
assert!(tool_names.contains(&"Read"));
|
||||||
|
assert!(tool_names.contains(&EXIT_TOOL_NAME));
|
||||||
|
assert!(converted["messages"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|message| message
|
||||||
|
.get("content")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.is_some_and(|content| content.contains("Tool mode is active"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_exit_tool_response_to_final_text() {
|
||||||
|
let request = json!({ "model": "claude-sonnet-4" });
|
||||||
|
let response = json!({
|
||||||
|
"id": "chatcmpl_1",
|
||||||
|
"choices": [{
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": null,
|
||||||
|
"tool_calls": [{
|
||||||
|
"id": "call_exit",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "ExitTool",
|
||||||
|
"arguments": "{\"response\":\"done\"}"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"finish_reason": "tool_calls"
|
||||||
|
}],
|
||||||
|
"usage": { "prompt_tokens": 5, "completion_tokens": 2 }
|
||||||
|
});
|
||||||
|
|
||||||
|
let converted = openai_to_anthropic_message(&request, &response, &credential()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(converted["stop_reason"], "end_turn");
|
||||||
|
assert_eq!(converted["content"][0]["type"], "text");
|
||||||
|
assert_eq!(converted["content"][0]["text"], "done");
|
||||||
|
}
|
||||||
|
}
|
||||||
214
apps/desktop/src-tauri/src/anthropic_proxy/providers.rs
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
use super::transformers::ProxyTransformerChain;
|
||||||
|
use super::OpenAiProxyCredential;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
const DEEPSEEK_MAX_TOKENS: u64 = 8192;
|
||||||
|
|
||||||
|
pub(super) fn apply_provider_request_transforms(
|
||||||
|
openai_request: &mut Value,
|
||||||
|
anthropic_request: &Value,
|
||||||
|
credential: &OpenAiProxyCredential,
|
||||||
|
wants_stream: bool,
|
||||||
|
transformers: &ProxyTransformerChain,
|
||||||
|
) {
|
||||||
|
if transformers.has_cleancache() {
|
||||||
|
clean_cache_control(openai_request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if wants_stream && transformers.has_streamoptions() {
|
||||||
|
openai_request["stream_options"] = json!({ "include_usage": true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if transformers.has_deepseek() {
|
||||||
|
cap_number_field(openai_request, "max_tokens", DEEPSEEK_MAX_TOKENS);
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_reasoning_budget(openai_request, anthropic_request);
|
||||||
|
apply_max_completion_tokens_compat(openai_request, credential);
|
||||||
|
clean_null_optional_fields(openai_request);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cap_number_field(body: &mut Value, key: &str, max: u64) {
|
||||||
|
let Some(value) = body.get(key).and_then(|value| value.as_u64()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if value > max {
|
||||||
|
body[key] = Value::Number(max.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_reasoning_budget(openai_request: &mut Value, anthropic_request: &Value) {
|
||||||
|
let Some(thinking) = anthropic_request.get("thinking") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if thinking.get("type").and_then(|value| value.as_str()) != Some("enabled") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(budget_tokens) = thinking
|
||||||
|
.get("budget_tokens")
|
||||||
|
.and_then(|value| value.as_u64())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if budget_tokens > 0 {
|
||||||
|
openai_request["reasoning"] = json!({ "max_tokens": budget_tokens });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_max_completion_tokens_compat(
|
||||||
|
openai_request: &mut Value,
|
||||||
|
credential: &OpenAiProxyCredential,
|
||||||
|
) {
|
||||||
|
if !uses_max_completion_tokens(credential) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(max_tokens) = openai_request.get("max_tokens").cloned() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
openai_request["max_completion_tokens"] = max_tokens;
|
||||||
|
if let Some(object) = openai_request.as_object_mut() {
|
||||||
|
object.remove("max_tokens");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uses_max_completion_tokens(credential: &OpenAiProxyCredential) -> bool {
|
||||||
|
let base_url = credential.base_url.to_ascii_lowercase();
|
||||||
|
let model = credential.model.to_ascii_lowercase();
|
||||||
|
let is_openai = base_url.contains("api.openai.com") || base_url.contains("openai.azure.com");
|
||||||
|
is_openai
|
||||||
|
&& (model.starts_with("o1")
|
||||||
|
|| model.starts_with("o3")
|
||||||
|
|| model.starts_with("o4")
|
||||||
|
|| model.starts_with("gpt-5"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean_cache_control(value: &mut Value) {
|
||||||
|
match value {
|
||||||
|
Value::Array(values) => {
|
||||||
|
for value in values {
|
||||||
|
clean_cache_control(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Object(object) => {
|
||||||
|
object.remove("cache_control");
|
||||||
|
for value in object.values_mut() {
|
||||||
|
clean_cache_control(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean_null_optional_fields(value: &mut Value) {
|
||||||
|
let Some(object) = value.as_object_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for key in ["tool_choice", "stop", "stream_options", "reasoning"] {
|
||||||
|
if object.get(key).is_some_and(Value::is_null) {
|
||||||
|
object.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn credential(base_url: &str, model: &str) -> OpenAiProxyCredential {
|
||||||
|
OpenAiProxyCredential {
|
||||||
|
api_key: "sk-test".to_string(),
|
||||||
|
base_url: base_url.to_string(),
|
||||||
|
model: model.to_string(),
|
||||||
|
transformers: Vec::new(),
|
||||||
|
model_transformers: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adds_usage_stream_options_for_streaming_requests() {
|
||||||
|
let mut body = json!({ "stream": true });
|
||||||
|
apply_provider_request_transforms(
|
||||||
|
&mut body,
|
||||||
|
&json!({}),
|
||||||
|
&credential("https://api.example.com/v1", "qwen-test"),
|
||||||
|
true,
|
||||||
|
&ProxyTransformerChain::from_names(&["streamoptions"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(body["stream_options"]["include_usage"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn caps_deepseek_max_tokens() {
|
||||||
|
let mut body = json!({ "max_tokens": 20000 });
|
||||||
|
apply_provider_request_transforms(
|
||||||
|
&mut body,
|
||||||
|
&json!({}),
|
||||||
|
&credential("https://api.deepseek.com", "deepseek-chat"),
|
||||||
|
false,
|
||||||
|
&ProxyTransformerChain::from_names(&["deepseek"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(body["max_tokens"], DEEPSEEK_MAX_TOKENS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copies_anthropic_thinking_budget_as_reasoning() {
|
||||||
|
let mut body = json!({});
|
||||||
|
apply_provider_request_transforms(
|
||||||
|
&mut body,
|
||||||
|
&json!({
|
||||||
|
"thinking": {
|
||||||
|
"type": "enabled",
|
||||||
|
"budget_tokens": 4096
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
&credential("https://api.example.com/v1", "qwen-test"),
|
||||||
|
false,
|
||||||
|
&ProxyTransformerChain::from_names(&[]),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(body["reasoning"]["max_tokens"], 4096);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_openai_reasoning_models_to_max_completion_tokens() {
|
||||||
|
let mut body = json!({ "max_tokens": 12000 });
|
||||||
|
apply_provider_request_transforms(
|
||||||
|
&mut body,
|
||||||
|
&json!({}),
|
||||||
|
&credential("https://api.openai.com/v1", "o3"),
|
||||||
|
false,
|
||||||
|
&ProxyTransformerChain::from_names(&[]),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(body.get("max_tokens").is_none());
|
||||||
|
assert_eq!(body["max_completion_tokens"], 12000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_cache_control_from_openai_compatible_requests() {
|
||||||
|
let mut body = json!({
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "text",
|
||||||
|
"text": "hello",
|
||||||
|
"cache_control": { "type": "ephemeral" }
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
apply_provider_request_transforms(
|
||||||
|
&mut body,
|
||||||
|
&json!({}),
|
||||||
|
&credential("https://api.example.com/v1", "qwen-test"),
|
||||||
|
false,
|
||||||
|
&ProxyTransformerChain::from_names(&["cleancache"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(body["messages"][0]["content"][0]
|
||||||
|
.get("cache_control")
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
925
apps/desktop/src-tauri/src/anthropic_proxy/stream.rs
Normal file
|
|
@ -0,0 +1,925 @@
|
||||||
|
use super::tools::{normalized_tool_call_id, repair_tool_arguments};
|
||||||
|
use super::{http_response, OpenAiProxyCredential};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct OpenAiStreamState {
|
||||||
|
message_started: bool,
|
||||||
|
completed: bool,
|
||||||
|
message_id: Option<String>,
|
||||||
|
model: Option<String>,
|
||||||
|
next_block_index: usize,
|
||||||
|
text_block_index: Option<usize>,
|
||||||
|
thinking_block_index: Option<usize>,
|
||||||
|
tool_blocks: HashMap<i64, StreamToolBlock>,
|
||||||
|
stop_reason: Option<String>,
|
||||||
|
output_tokens: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct StreamToolBlock {
|
||||||
|
id: Option<String>,
|
||||||
|
name: Option<String>,
|
||||||
|
buffered_arguments: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn stream_openai_sse_to_anthropic(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
mut response: reqwest::Response,
|
||||||
|
anthropic_request: &Value,
|
||||||
|
credential: &OpenAiProxyCredential,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
stream
|
||||||
|
.write_all(streaming_http_headers().as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Failed to write proxy stream headers: {}", err))?;
|
||||||
|
|
||||||
|
let mut state = OpenAiStreamState::default();
|
||||||
|
let mut buffer = String::new();
|
||||||
|
while let Some(chunk) = match response.chunk().await {
|
||||||
|
Ok(chunk) => chunk,
|
||||||
|
Err(err) => {
|
||||||
|
let rendered =
|
||||||
|
anthropic_stream_error_sse(&format!("Provider stream ended unexpectedly: {}", err));
|
||||||
|
let _ = write_stream_body(stream, &rendered, "provider stream error").await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
} {
|
||||||
|
buffer.push_str(&String::from_utf8_lossy(&chunk));
|
||||||
|
while let Some((event, rest)) = take_next_sse_event(&buffer) {
|
||||||
|
buffer = rest;
|
||||||
|
let rendered =
|
||||||
|
openai_sse_event_to_anthropic(&mut state, &event, anthropic_request, credential);
|
||||||
|
if !write_stream_body(stream, &rendered, "proxy stream event").await {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !buffer.trim().is_empty() {
|
||||||
|
let rendered =
|
||||||
|
openai_sse_event_to_anthropic(&mut state, &buffer, anthropic_request, credential);
|
||||||
|
if !write_stream_body(stream, &rendered, "final proxy stream event").await {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rendered = finish_anthropic_stream(&mut state);
|
||||||
|
let _ = write_stream_body(stream, &rendered, "proxy stream completion").await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn streaming_http_headers() -> String {
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_stream_body(stream: &mut TcpStream, body: &str, context: &str) -> bool {
|
||||||
|
if body.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match stream.write_all(body.as_bytes()).await {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("[anthropic-proxy] failed to write {}: {}", context, err);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn anthropic_stream_error_sse(message: &str) -> String {
|
||||||
|
let mut body = String::new();
|
||||||
|
push_sse(
|
||||||
|
&mut body,
|
||||||
|
"error",
|
||||||
|
&json!({
|
||||||
|
"type": "error",
|
||||||
|
"error": {
|
||||||
|
"type": "api_error",
|
||||||
|
"message": message,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_next_sse_event(buffer: &str) -> Option<(String, String)> {
|
||||||
|
if let Some(index) = buffer.find("\n\n") {
|
||||||
|
let event = buffer[..index].to_string();
|
||||||
|
let rest = buffer[index + 2..].to_string();
|
||||||
|
return Some((event, rest));
|
||||||
|
}
|
||||||
|
if let Some(index) = buffer.find("\r\n\r\n") {
|
||||||
|
let event = buffer[..index].to_string();
|
||||||
|
let rest = buffer[index + 4..].to_string();
|
||||||
|
return Some((event, rest));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn openai_sse_event_to_anthropic(
|
||||||
|
state: &mut OpenAiStreamState,
|
||||||
|
event: &str,
|
||||||
|
anthropic_request: &Value,
|
||||||
|
credential: &OpenAiProxyCredential,
|
||||||
|
) -> String {
|
||||||
|
let Some(data) = sse_event_data(event) else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
if data.trim() == "[DONE]" {
|
||||||
|
return finish_anthropic_stream(state);
|
||||||
|
}
|
||||||
|
let Ok(chunk) = serde_json::from_str::<Value>(&data) else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
openai_stream_chunk_to_anthropic(state, &chunk, anthropic_request, credential)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sse_event_data(event: &str) -> Option<String> {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
for line in event.lines() {
|
||||||
|
let line = line.trim_end_matches('\r');
|
||||||
|
if let Some(data) = line.strip_prefix("data:") {
|
||||||
|
parts.push(data.trim_start());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parts.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(parts.join("\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn openai_stream_chunk_to_anthropic(
|
||||||
|
state: &mut OpenAiStreamState,
|
||||||
|
chunk: &Value,
|
||||||
|
anthropic_request: &Value,
|
||||||
|
credential: &OpenAiProxyCredential,
|
||||||
|
) -> String {
|
||||||
|
let mut body = String::new();
|
||||||
|
ensure_stream_message_started(state, &mut body, chunk, anthropic_request, credential);
|
||||||
|
|
||||||
|
if let Some(usage) = chunk.get("usage") {
|
||||||
|
state.output_tokens = usage_token(
|
||||||
|
usage,
|
||||||
|
&[
|
||||||
|
"completion_tokens",
|
||||||
|
"output_tokens",
|
||||||
|
"completion_token_count",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(choice) = chunk
|
||||||
|
.get("choices")
|
||||||
|
.and_then(|value| value.as_array())
|
||||||
|
.and_then(|choices| choices.first())
|
||||||
|
else {
|
||||||
|
return body;
|
||||||
|
};
|
||||||
|
let delta = choice.get("delta").unwrap_or(&Value::Null);
|
||||||
|
|
||||||
|
if let Some(reasoning) = delta_text(delta, &["reasoning_content", "reasoning"]) {
|
||||||
|
push_stream_text_delta(state, &mut body, "thinking", &reasoning);
|
||||||
|
}
|
||||||
|
if let Some(thinking) = delta
|
||||||
|
.get("thinking")
|
||||||
|
.and_then(|value| {
|
||||||
|
value
|
||||||
|
.get("content")
|
||||||
|
.and_then(|content| content.as_str())
|
||||||
|
.or_else(|| value.as_str())
|
||||||
|
})
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
push_stream_text_delta(state, &mut body, "thinking", thinking);
|
||||||
|
}
|
||||||
|
if let Some(content) = delta_text(delta, &["content"]) {
|
||||||
|
push_stream_text_delta(state, &mut body, "text", &content);
|
||||||
|
}
|
||||||
|
if let Some(tool_calls) = delta.get("tool_calls").and_then(|value| value.as_array()) {
|
||||||
|
for call in tool_calls {
|
||||||
|
push_stream_tool_delta(state, call);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(finish_reason) = choice.get("finish_reason").and_then(|value| value.as_str()) {
|
||||||
|
if !finish_reason.is_empty() {
|
||||||
|
state.stop_reason = Some(map_openai_finish_reason(finish_reason).to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_stream_message_started(
|
||||||
|
state: &mut OpenAiStreamState,
|
||||||
|
body: &mut String,
|
||||||
|
chunk: &Value,
|
||||||
|
anthropic_request: &Value,
|
||||||
|
credential: &OpenAiProxyCredential,
|
||||||
|
) {
|
||||||
|
if state.message_started {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.message_started = true;
|
||||||
|
state.message_id = chunk
|
||||||
|
.get("id")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(str::to_string)
|
||||||
|
.or_else(|| Some(format!("msg_{}", uuid::Uuid::new_v4().simple())));
|
||||||
|
state.model = anthropic_request
|
||||||
|
.get("model")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(str::to_string)
|
||||||
|
.or_else(|| {
|
||||||
|
chunk
|
||||||
|
.get("model")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
.or_else(|| Some(credential.model.clone()));
|
||||||
|
|
||||||
|
push_sse(
|
||||||
|
body,
|
||||||
|
"message_start",
|
||||||
|
&json!({
|
||||||
|
"type": "message_start",
|
||||||
|
"message": {
|
||||||
|
"id": state.message_id.clone().unwrap_or_else(|| format!("msg_{}", uuid::Uuid::new_v4().simple())),
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": state.model.clone().unwrap_or_else(|| credential.model.clone()),
|
||||||
|
"content": [],
|
||||||
|
"stop_reason": Value::Null,
|
||||||
|
"stop_sequence": Value::Null,
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 0,
|
||||||
|
"output_tokens": 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delta_text(delta: &Value, keys: &[&str]) -> Option<String> {
|
||||||
|
keys.iter()
|
||||||
|
.find_map(|key| delta.get(*key).and_then(|value| value.as_str()))
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_stream_text_delta(
|
||||||
|
state: &mut OpenAiStreamState,
|
||||||
|
body: &mut String,
|
||||||
|
block_type: &str,
|
||||||
|
text: &str,
|
||||||
|
) {
|
||||||
|
if block_type != "thinking" {
|
||||||
|
close_thinking_block_if_open(state, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let block_index = if block_type == "thinking" {
|
||||||
|
if let Some(index) = state.thinking_block_index {
|
||||||
|
index
|
||||||
|
} else {
|
||||||
|
let index = state.next_block_index;
|
||||||
|
state.next_block_index += 1;
|
||||||
|
state.thinking_block_index = Some(index);
|
||||||
|
push_sse(
|
||||||
|
body,
|
||||||
|
"content_block_start",
|
||||||
|
&json!({
|
||||||
|
"type": "content_block_start",
|
||||||
|
"index": index,
|
||||||
|
"content_block": {
|
||||||
|
"type": "thinking",
|
||||||
|
"thinking": "",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
index
|
||||||
|
}
|
||||||
|
} else if let Some(index) = state.text_block_index {
|
||||||
|
index
|
||||||
|
} else {
|
||||||
|
let index = state.next_block_index;
|
||||||
|
state.next_block_index += 1;
|
||||||
|
state.text_block_index = Some(index);
|
||||||
|
push_sse(
|
||||||
|
body,
|
||||||
|
"content_block_start",
|
||||||
|
&json!({
|
||||||
|
"type": "content_block_start",
|
||||||
|
"index": index,
|
||||||
|
"content_block": {
|
||||||
|
"type": "text",
|
||||||
|
"text": "",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
index
|
||||||
|
};
|
||||||
|
|
||||||
|
let (delta_type, key) = if block_type == "thinking" {
|
||||||
|
("thinking_delta", "thinking")
|
||||||
|
} else {
|
||||||
|
("text_delta", "text")
|
||||||
|
};
|
||||||
|
push_sse(
|
||||||
|
body,
|
||||||
|
"content_block_delta",
|
||||||
|
&json!({
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"index": block_index,
|
||||||
|
"delta": {
|
||||||
|
"type": delta_type,
|
||||||
|
key: text,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_stream_tool_delta(state: &mut OpenAiStreamState, call: &Value) {
|
||||||
|
let openai_index = call
|
||||||
|
.get("index")
|
||||||
|
.and_then(|value| value.as_i64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let block = state.tool_blocks.entry(openai_index).or_default();
|
||||||
|
if let Some(id) = call.get("id").and_then(|value| value.as_str()) {
|
||||||
|
block.id = Some(normalized_tool_call_id(Some(id)));
|
||||||
|
}
|
||||||
|
let function = call.get("function").unwrap_or(&Value::Null);
|
||||||
|
if let Some(name) = function.get("name").and_then(|value| value.as_str()) {
|
||||||
|
if !name.is_empty() {
|
||||||
|
block.name = Some(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(arguments) = function.get("arguments").and_then(|value| value.as_str()) {
|
||||||
|
block.buffered_arguments.push_str(arguments);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close_thinking_block_if_open(state: &mut OpenAiStreamState, body: &mut String) {
|
||||||
|
if let Some(index) = state.thinking_block_index.take() {
|
||||||
|
push_sse(
|
||||||
|
body,
|
||||||
|
"content_block_delta",
|
||||||
|
&json!({
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"index": index,
|
||||||
|
"delta": {
|
||||||
|
"type": "signature_delta",
|
||||||
|
"signature": format!("ccr_{}", uuid::Uuid::new_v4().simple()),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
push_content_block_stop(body, index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_anthropic_stream(state: &mut OpenAiStreamState) -> String {
|
||||||
|
if state.completed {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
state.completed = true;
|
||||||
|
let mut body = String::new();
|
||||||
|
if !state.message_started {
|
||||||
|
state.message_started = true;
|
||||||
|
let message_id = format!("msg_{}", uuid::Uuid::new_v4().simple());
|
||||||
|
state.message_id = Some(message_id.clone());
|
||||||
|
push_sse(
|
||||||
|
&mut body,
|
||||||
|
"message_start",
|
||||||
|
&json!({
|
||||||
|
"type": "message_start",
|
||||||
|
"message": {
|
||||||
|
"id": message_id,
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": state.model.clone().unwrap_or_else(|| "claude-prism-proxy".to_string()),
|
||||||
|
"content": [],
|
||||||
|
"stop_reason": Value::Null,
|
||||||
|
"stop_sequence": Value::Null,
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 0,
|
||||||
|
"output_tokens": 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
close_thinking_block_if_open(state, &mut body);
|
||||||
|
if let Some(index) = state.text_block_index.take() {
|
||||||
|
push_content_block_stop(&mut body, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tool_blocks = state
|
||||||
|
.tool_blocks
|
||||||
|
.iter()
|
||||||
|
.map(|(openai_index, block)| (*openai_index, block))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
tool_blocks.sort_by_key(|(openai_index, _)| *openai_index);
|
||||||
|
let exit_tool_response = if tool_blocks
|
||||||
|
.iter()
|
||||||
|
.all(|(_, block)| block.name.as_deref() == Some("ExitTool"))
|
||||||
|
{
|
||||||
|
tool_blocks
|
||||||
|
.iter()
|
||||||
|
.find_map(|(_, block)| exit_tool_response(&block.buffered_arguments))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(response) = exit_tool_response {
|
||||||
|
push_stream_text_delta(state, &mut body, "text", &response);
|
||||||
|
if let Some(index) = state.text_block_index.take() {
|
||||||
|
push_content_block_stop(&mut body, index);
|
||||||
|
}
|
||||||
|
state.tool_blocks.clear();
|
||||||
|
state.stop_reason = Some("end_turn".to_string());
|
||||||
|
}
|
||||||
|
let mut tool_blocks = state
|
||||||
|
.tool_blocks
|
||||||
|
.iter()
|
||||||
|
.map(|(openai_index, block)| (*openai_index, block))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
tool_blocks.sort_by_key(|(openai_index, _)| *openai_index);
|
||||||
|
for (_, block) in tool_blocks {
|
||||||
|
let index = state.next_block_index;
|
||||||
|
state.next_block_index += 1;
|
||||||
|
push_sse(
|
||||||
|
&mut body,
|
||||||
|
"content_block_start",
|
||||||
|
&json!({
|
||||||
|
"type": "content_block_start",
|
||||||
|
"index": index,
|
||||||
|
"content_block": {
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": block.id.clone().unwrap_or_else(|| normalized_tool_call_id(None)),
|
||||||
|
"name": block.name.clone().unwrap_or_else(|| "unknown".to_string()),
|
||||||
|
"input": {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let repaired_arguments = repair_tool_arguments(&block.buffered_arguments);
|
||||||
|
if repaired_arguments != "{}" || !block.buffered_arguments.trim().is_empty() {
|
||||||
|
push_sse(
|
||||||
|
&mut body,
|
||||||
|
"content_block_delta",
|
||||||
|
&json!({
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"index": index,
|
||||||
|
"delta": {
|
||||||
|
"type": "input_json_delta",
|
||||||
|
"partial_json": repaired_arguments,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
push_content_block_stop(&mut body, index);
|
||||||
|
}
|
||||||
|
let stop_reason = if state.tool_blocks.is_empty() {
|
||||||
|
state
|
||||||
|
.stop_reason
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "end_turn".to_string())
|
||||||
|
} else {
|
||||||
|
"tool_use".to_string()
|
||||||
|
};
|
||||||
|
push_sse(
|
||||||
|
&mut body,
|
||||||
|
"message_delta",
|
||||||
|
&json!({
|
||||||
|
"type": "message_delta",
|
||||||
|
"delta": {
|
||||||
|
"stop_reason": stop_reason,
|
||||||
|
"stop_sequence": Value::Null,
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"output_tokens": state.output_tokens,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
push_sse(
|
||||||
|
&mut body,
|
||||||
|
"message_stop",
|
||||||
|
&json!({ "type": "message_stop" }),
|
||||||
|
);
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exit_tool_response(arguments: &str) -> Option<String> {
|
||||||
|
let repaired = repair_tool_arguments(arguments);
|
||||||
|
serde_json::from_str::<Value>(&repaired)
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| {
|
||||||
|
value
|
||||||
|
.get("response")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_content_block_stop(body: &mut String, index: usize) {
|
||||||
|
push_sse(
|
||||||
|
body,
|
||||||
|
"content_block_stop",
|
||||||
|
&json!({
|
||||||
|
"type": "content_block_stop",
|
||||||
|
"index": index,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_openai_finish_reason(reason: &str) -> &str {
|
||||||
|
match reason {
|
||||||
|
"length" => "max_tokens",
|
||||||
|
"tool_calls" => "tool_use",
|
||||||
|
_ => "end_turn",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn sse_response(message: &Value) -> String {
|
||||||
|
let content = message
|
||||||
|
.get("content")
|
||||||
|
.and_then(|value| value.as_array())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let input_tokens = message
|
||||||
|
.pointer("/usage/input_tokens")
|
||||||
|
.and_then(|value| value.as_u64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let output_tokens = message
|
||||||
|
.pointer("/usage/output_tokens")
|
||||||
|
.and_then(|value| value.as_u64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let start = json!({
|
||||||
|
"type": "message_start",
|
||||||
|
"message": {
|
||||||
|
"id": message.get("id").cloned().unwrap_or_else(|| json!(format!("msg_{}", uuid::Uuid::new_v4().simple()))),
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": message.get("model").cloned().unwrap_or_else(|| json!("claude-prism-proxy")),
|
||||||
|
"content": [],
|
||||||
|
"stop_reason": Value::Null,
|
||||||
|
"stop_sequence": Value::Null,
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": input_tokens,
|
||||||
|
"output_tokens": 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut body = String::new();
|
||||||
|
push_sse(&mut body, "message_start", &start);
|
||||||
|
for (index, block) in content.iter().enumerate() {
|
||||||
|
let block_type = block
|
||||||
|
.get("type")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("text");
|
||||||
|
match block_type {
|
||||||
|
"tool_use" => {
|
||||||
|
let content_block = json!({
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": block.get("id").cloned().unwrap_or_else(|| json!(format!("toolu_{}", uuid::Uuid::new_v4().simple()))),
|
||||||
|
"name": block.get("name").cloned().unwrap_or_else(|| json!("unknown")),
|
||||||
|
"input": {},
|
||||||
|
});
|
||||||
|
push_sse(
|
||||||
|
&mut body,
|
||||||
|
"content_block_start",
|
||||||
|
&json!({
|
||||||
|
"type": "content_block_start",
|
||||||
|
"index": index,
|
||||||
|
"content_block": content_block,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let input = block.get("input").cloned().unwrap_or_else(|| json!({}));
|
||||||
|
push_sse(
|
||||||
|
&mut body,
|
||||||
|
"content_block_delta",
|
||||||
|
&json!({
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"index": index,
|
||||||
|
"delta": {
|
||||||
|
"type": "input_json_delta",
|
||||||
|
"partial_json": input.to_string(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"thinking" => {
|
||||||
|
push_text_like_sse_block(
|
||||||
|
&mut body,
|
||||||
|
index,
|
||||||
|
"thinking",
|
||||||
|
block
|
||||||
|
.get("thinking")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
push_text_like_sse_block(
|
||||||
|
&mut body,
|
||||||
|
index,
|
||||||
|
"text",
|
||||||
|
block
|
||||||
|
.get("text")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
push_content_block_stop(&mut body, index);
|
||||||
|
}
|
||||||
|
push_sse(
|
||||||
|
&mut body,
|
||||||
|
"message_delta",
|
||||||
|
&json!({
|
||||||
|
"type": "message_delta",
|
||||||
|
"delta": {
|
||||||
|
"stop_reason": message.get("stop_reason").cloned().unwrap_or_else(|| json!("end_turn")),
|
||||||
|
"stop_sequence": Value::Null,
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"output_tokens": output_tokens,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
push_sse(
|
||||||
|
&mut body,
|
||||||
|
"message_stop",
|
||||||
|
&json!({ "type": "message_stop" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
http_response(
|
||||||
|
200,
|
||||||
|
"text/event-stream; charset=utf-8",
|
||||||
|
&format!("{}{}", body, "\n"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_text_like_sse_block(body: &mut String, index: usize, block_type: &str, text: &str) {
|
||||||
|
let content_block = if block_type == "thinking" {
|
||||||
|
json!({
|
||||||
|
"type": "thinking",
|
||||||
|
"thinking": "",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
json!({
|
||||||
|
"type": "text",
|
||||||
|
"text": "",
|
||||||
|
})
|
||||||
|
};
|
||||||
|
push_sse(
|
||||||
|
body,
|
||||||
|
"content_block_start",
|
||||||
|
&json!({
|
||||||
|
"type": "content_block_start",
|
||||||
|
"index": index,
|
||||||
|
"content_block": content_block,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if !text.is_empty() {
|
||||||
|
let delta_type = if block_type == "thinking" {
|
||||||
|
"thinking_delta"
|
||||||
|
} else {
|
||||||
|
"text_delta"
|
||||||
|
};
|
||||||
|
let delta_key = if block_type == "thinking" {
|
||||||
|
"thinking"
|
||||||
|
} else {
|
||||||
|
"text"
|
||||||
|
};
|
||||||
|
push_sse(
|
||||||
|
body,
|
||||||
|
"content_block_delta",
|
||||||
|
&json!({
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"index": index,
|
||||||
|
"delta": {
|
||||||
|
"type": delta_type,
|
||||||
|
delta_key: text,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if block_type == "thinking" {
|
||||||
|
push_sse(
|
||||||
|
body,
|
||||||
|
"content_block_delta",
|
||||||
|
&json!({
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"index": index,
|
||||||
|
"delta": {
|
||||||
|
"type": "signature_delta",
|
||||||
|
"signature": format!("ccr_{}", uuid::Uuid::new_v4().simple()),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_sse(body: &mut String, event: &str, data: &Value) {
|
||||||
|
body.push_str("event: ");
|
||||||
|
body.push_str(event);
|
||||||
|
body.push('\n');
|
||||||
|
body.push_str("data: ");
|
||||||
|
body.push_str(&data.to_string());
|
||||||
|
body.push_str("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage_token(usage: &Value, keys: &[&str]) -> u64 {
|
||||||
|
keys.iter()
|
||||||
|
.find_map(|key| usage.get(*key).and_then(|value| value.as_u64()))
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn credential() -> OpenAiProxyCredential {
|
||||||
|
OpenAiProxyCredential {
|
||||||
|
api_key: "sk-test".to_string(),
|
||||||
|
base_url: "https://api.example.com/v1".to_string(),
|
||||||
|
model: "qwen-test".to_string(),
|
||||||
|
transformers: Vec::new(),
|
||||||
|
model_transformers: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_provider_stream_errors_as_anthropic_sse_errors() {
|
||||||
|
let rendered = anthropic_stream_error_sse("provider stream broke");
|
||||||
|
|
||||||
|
assert!(rendered.contains("event: error"));
|
||||||
|
assert!(rendered.contains("\"type\":\"error\""));
|
||||||
|
assert!(rendered.contains("\"type\":\"api_error\""));
|
||||||
|
assert!(rendered.contains("\"message\":\"provider stream broke\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn streams_openai_text_delta_as_anthropic_sse() {
|
||||||
|
let request = json!({ "model": "claude-sonnet-4" });
|
||||||
|
let mut state = OpenAiStreamState::default();
|
||||||
|
let chunk = json!({
|
||||||
|
"id": "chatcmpl_1",
|
||||||
|
"model": "qwen-test",
|
||||||
|
"choices": [{
|
||||||
|
"delta": { "content": "Hello" },
|
||||||
|
"finish_reason": null
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let first = openai_stream_chunk_to_anthropic(&mut state, &chunk, &request, &credential());
|
||||||
|
let done = finish_anthropic_stream(&mut state);
|
||||||
|
let combined = format!("{}{}", first, done);
|
||||||
|
|
||||||
|
assert!(combined.contains("event: message_start"));
|
||||||
|
assert!(combined.contains("\"model\":\"claude-sonnet-4\""));
|
||||||
|
assert!(combined.contains("\"type\":\"text_delta\""));
|
||||||
|
assert!(combined.contains("\"text\":\"Hello\""));
|
||||||
|
assert!(combined.contains("\"stop_reason\":\"end_turn\""));
|
||||||
|
assert!(finish_anthropic_stream(&mut state).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn streams_reasoning_content_as_thinking_delta() {
|
||||||
|
let request = json!({ "model": "claude-sonnet-4" });
|
||||||
|
let mut state = OpenAiStreamState::default();
|
||||||
|
let chunk = json!({
|
||||||
|
"id": "chatcmpl_1",
|
||||||
|
"choices": [{
|
||||||
|
"delta": { "reasoning_content": "I should inspect files." },
|
||||||
|
"finish_reason": null
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let rendered =
|
||||||
|
openai_stream_chunk_to_anthropic(&mut state, &chunk, &request, &credential());
|
||||||
|
|
||||||
|
assert!(rendered.contains("\"type\":\"thinking\""));
|
||||||
|
assert!(rendered.contains("\"type\":\"thinking_delta\""));
|
||||||
|
assert!(rendered.contains("\"thinking\":\"I should inspect files.\""));
|
||||||
|
let done = finish_anthropic_stream(&mut state);
|
||||||
|
assert!(done.contains("\"type\":\"signature_delta\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn buffers_and_repairs_streamed_tool_arguments() {
|
||||||
|
let request = json!({ "model": "claude-sonnet-4" });
|
||||||
|
let mut state = OpenAiStreamState::default();
|
||||||
|
let first_chunk = json!({
|
||||||
|
"id": "chatcmpl_1",
|
||||||
|
"choices": [{
|
||||||
|
"delta": {
|
||||||
|
"tool_calls": [{
|
||||||
|
"index": 0,
|
||||||
|
"id": "call_1",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "Read",
|
||||||
|
"arguments": "{\"file_path\":"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"finish_reason": null
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
let second_chunk = json!({
|
||||||
|
"id": "chatcmpl_1",
|
||||||
|
"choices": [{
|
||||||
|
"delta": {
|
||||||
|
"tool_calls": [{
|
||||||
|
"index": 0,
|
||||||
|
"function": { "arguments": "\"main.tex\"" }
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"finish_reason": "tool_calls"
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let first =
|
||||||
|
openai_stream_chunk_to_anthropic(&mut state, &first_chunk, &request, &credential());
|
||||||
|
let second =
|
||||||
|
openai_stream_chunk_to_anthropic(&mut state, &second_chunk, &request, &credential());
|
||||||
|
let done = finish_anthropic_stream(&mut state);
|
||||||
|
let combined = format!("{}{}{}", first, second, done);
|
||||||
|
|
||||||
|
assert!(!first.contains("\"type\":\"tool_use\""));
|
||||||
|
assert!(!second.contains("\"type\":\"tool_use\""));
|
||||||
|
assert!(combined.contains("\"type\":\"tool_use\""));
|
||||||
|
assert!(combined.contains("\"id\":\"call_1\""));
|
||||||
|
assert!(combined.contains("\"name\":\"Read\""));
|
||||||
|
assert!(combined.contains("\"type\":\"input_json_delta\""));
|
||||||
|
assert!(combined.contains("{\\\"file_path\\\":\\\"main.tex\\\"}"));
|
||||||
|
assert!(combined.contains("\"stop_reason\":\"tool_use\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_numeric_streamed_tool_call_ids() {
|
||||||
|
let request = json!({ "model": "claude-sonnet-4" });
|
||||||
|
let mut state = OpenAiStreamState::default();
|
||||||
|
let chunk = json!({
|
||||||
|
"id": "chatcmpl_1",
|
||||||
|
"choices": [{
|
||||||
|
"delta": {
|
||||||
|
"tool_calls": [{
|
||||||
|
"index": 0,
|
||||||
|
"id": "123",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "Read",
|
||||||
|
"arguments": "{\"file_path\":\"main.tex\"}"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"finish_reason": "tool_calls"
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
openai_stream_chunk_to_anthropic(&mut state, &chunk, &request, &credential());
|
||||||
|
let done = finish_anthropic_stream(&mut state);
|
||||||
|
|
||||||
|
assert!(done.contains("\"id\":\"call_"));
|
||||||
|
assert!(!done.contains("\"id\":\"123\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn streams_exit_tool_as_final_text() {
|
||||||
|
let request = json!({ "model": "claude-sonnet-4" });
|
||||||
|
let mut state = OpenAiStreamState::default();
|
||||||
|
let chunk = json!({
|
||||||
|
"id": "chatcmpl_1",
|
||||||
|
"choices": [{
|
||||||
|
"delta": {
|
||||||
|
"tool_calls": [{
|
||||||
|
"index": 0,
|
||||||
|
"id": "call_exit",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "ExitTool",
|
||||||
|
"arguments": "{\"response\":\"all done\"}"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"finish_reason": "tool_calls"
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
openai_stream_chunk_to_anthropic(&mut state, &chunk, &request, &credential());
|
||||||
|
let done = finish_anthropic_stream(&mut state);
|
||||||
|
|
||||||
|
assert!(done.contains("\"type\":\"text_delta\""));
|
||||||
|
assert!(done.contains("\"text\":\"all done\""));
|
||||||
|
assert!(done.contains("\"stop_reason\":\"end_turn\""));
|
||||||
|
assert!(!done.contains("\"type\":\"tool_use\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
561
apps/desktop/src-tauri/src/anthropic_proxy/tools.rs
Normal file
|
|
@ -0,0 +1,561 @@
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
pub(super) fn repaired_tool_arguments_value(arguments: &str) -> Value {
|
||||||
|
serde_json::from_str::<Value>(&repair_tool_arguments(arguments)).unwrap_or_else(|_| json!({}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn normalized_tool_call_id(id: Option<&str>) -> String {
|
||||||
|
let id = id.unwrap_or_default().trim();
|
||||||
|
if id.is_empty() || id.chars().all(|ch| ch.is_ascii_digit()) {
|
||||||
|
format!("call_{}", uuid::Uuid::new_v4().simple())
|
||||||
|
} else {
|
||||||
|
id.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn repair_tool_arguments(arguments: &str) -> String {
|
||||||
|
let trimmed = trim_code_fence(arguments.trim());
|
||||||
|
if trimmed.is_empty() || trimmed == "{}" {
|
||||||
|
return "{}".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
push_candidate(&mut candidates, trimmed.to_string());
|
||||||
|
if let Some(extracted) = extract_json_like(trimmed) {
|
||||||
|
push_candidate(&mut candidates, extracted);
|
||||||
|
}
|
||||||
|
|
||||||
|
let seeds = candidates.clone();
|
||||||
|
for candidate in seeds {
|
||||||
|
let without_comments = strip_json_comments(&candidate);
|
||||||
|
push_candidate(&mut candidates, without_comments.clone());
|
||||||
|
|
||||||
|
let without_trailing_commas = remove_trailing_commas(&without_comments);
|
||||||
|
push_candidate(&mut candidates, without_trailing_commas.clone());
|
||||||
|
|
||||||
|
let json5_like =
|
||||||
|
normalize_single_quoted_strings("e_unquoted_object_keys(&without_trailing_commas));
|
||||||
|
push_candidate(&mut candidates, json5_like.clone());
|
||||||
|
|
||||||
|
if let Some(with_commas) = insert_missing_commas_between_fields(&json5_like) {
|
||||||
|
push_candidate(&mut candidates, with_commas.clone());
|
||||||
|
if let Some(balanced) = repair_balanced_json(with_commas) {
|
||||||
|
push_candidate(&mut candidates, balanced);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(balanced) = repair_balanced_json(json5_like) {
|
||||||
|
push_candidate(&mut candidates, balanced);
|
||||||
|
}
|
||||||
|
if let Some(balanced) = repair_balanced_json(without_trailing_commas) {
|
||||||
|
push_candidate(&mut candidates, balanced);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for candidate in candidates {
|
||||||
|
if let Some(repaired) = parse_tool_arguments_candidate(&candidate) {
|
||||||
|
return repaired;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"{}".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_candidate(candidates: &mut Vec<String>, value: String) {
|
||||||
|
let value = value.trim().to_string();
|
||||||
|
if value.is_empty() || candidates.iter().any(|candidate| candidate == &value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
candidates.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_tool_arguments_candidate(value: &str) -> Option<String> {
|
||||||
|
serde_json::from_str::<Value>(value)
|
||||||
|
.ok()
|
||||||
|
.or_else(|| serde_yaml::from_str::<Value>(value).ok())
|
||||||
|
.and_then(canonical_tool_arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_tool_arguments(value: Value) -> Option<String> {
|
||||||
|
match value {
|
||||||
|
Value::Object(map) => {
|
||||||
|
if map.keys().any(|key| key.contains(':')) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Value::Object(map).to_string())
|
||||||
|
}
|
||||||
|
Value::Array(_) => Some(value.to_string()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_code_fence(value: &str) -> &str {
|
||||||
|
let value = value.trim();
|
||||||
|
if !value.starts_with("```") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
let Some(first_newline) = value.find('\n') else {
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
let value = &value[first_newline + 1..];
|
||||||
|
value
|
||||||
|
.strip_suffix("```")
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or(value.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_json_like(value: &str) -> Option<String> {
|
||||||
|
let object_start = value.find('{');
|
||||||
|
let array_start = value.find('[');
|
||||||
|
let start = match (object_start, array_start) {
|
||||||
|
(Some(object), Some(array)) => object.min(array),
|
||||||
|
(Some(object), None) => object,
|
||||||
|
(None, Some(array)) => array,
|
||||||
|
(None, None) => return None,
|
||||||
|
};
|
||||||
|
let end = value.rfind('}').or_else(|| value.rfind(']'))?;
|
||||||
|
if end <= start {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(value[start..=end].to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repair_balanced_json(value: String) -> Option<String> {
|
||||||
|
let mut output = String::with_capacity(value.len() + 8);
|
||||||
|
let mut stack = Vec::new();
|
||||||
|
let mut in_string = false;
|
||||||
|
let mut escaped = false;
|
||||||
|
|
||||||
|
for ch in value.chars() {
|
||||||
|
output.push(ch);
|
||||||
|
if in_string {
|
||||||
|
if escaped {
|
||||||
|
escaped = false;
|
||||||
|
} else if ch == '\\' {
|
||||||
|
escaped = true;
|
||||||
|
} else if ch == '"' {
|
||||||
|
in_string = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match ch {
|
||||||
|
'"' => in_string = true,
|
||||||
|
'{' => stack.push('}'),
|
||||||
|
'[' => stack.push(']'),
|
||||||
|
'}' | ']' => {
|
||||||
|
if stack.last().copied() == Some(ch) {
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if in_string {
|
||||||
|
output.push('"');
|
||||||
|
}
|
||||||
|
while let Some(ch) = stack.pop() {
|
||||||
|
output.push(ch);
|
||||||
|
}
|
||||||
|
Some(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_trailing_commas(value: &str) -> String {
|
||||||
|
let mut output = String::with_capacity(value.len());
|
||||||
|
let mut chars = value.chars().peekable();
|
||||||
|
let mut in_string = false;
|
||||||
|
let mut escaped = false;
|
||||||
|
|
||||||
|
while let Some(ch) = chars.next() {
|
||||||
|
if in_string {
|
||||||
|
output.push(ch);
|
||||||
|
if escaped {
|
||||||
|
escaped = false;
|
||||||
|
} else if ch == '\\' {
|
||||||
|
escaped = true;
|
||||||
|
} else if ch == '"' {
|
||||||
|
in_string = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == '"' {
|
||||||
|
in_string = true;
|
||||||
|
output.push(ch);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == ',' {
|
||||||
|
let mut lookahead = chars.clone();
|
||||||
|
while matches!(lookahead.peek(), Some(next) if next.is_whitespace()) {
|
||||||
|
lookahead.next();
|
||||||
|
}
|
||||||
|
if matches!(lookahead.peek(), Some('}' | ']')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.push(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_json_comments(value: &str) -> String {
|
||||||
|
let mut output = String::with_capacity(value.len());
|
||||||
|
let mut chars = value.chars().peekable();
|
||||||
|
let mut in_double_string = false;
|
||||||
|
let mut in_single_string = false;
|
||||||
|
let mut escaped = false;
|
||||||
|
|
||||||
|
while let Some(ch) = chars.next() {
|
||||||
|
if in_double_string || in_single_string {
|
||||||
|
output.push(ch);
|
||||||
|
if escaped {
|
||||||
|
escaped = false;
|
||||||
|
} else if ch == '\\' {
|
||||||
|
escaped = true;
|
||||||
|
} else if in_double_string && ch == '"' {
|
||||||
|
in_double_string = false;
|
||||||
|
} else if in_single_string && ch == '\'' {
|
||||||
|
in_single_string = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match ch {
|
||||||
|
'"' => {
|
||||||
|
in_double_string = true;
|
||||||
|
output.push(ch);
|
||||||
|
}
|
||||||
|
'\'' => {
|
||||||
|
in_single_string = true;
|
||||||
|
output.push(ch);
|
||||||
|
}
|
||||||
|
'/' if chars.peek() == Some(&'/') => {
|
||||||
|
chars.next();
|
||||||
|
for next in chars.by_ref() {
|
||||||
|
if next == '\n' {
|
||||||
|
output.push('\n');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'/' if chars.peek() == Some(&'*') => {
|
||||||
|
chars.next();
|
||||||
|
let mut previous = '\0';
|
||||||
|
for next in chars.by_ref() {
|
||||||
|
if previous == '*' && next == '/' {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
previous = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => output.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quote_unquoted_object_keys(value: &str) -> String {
|
||||||
|
let mut output = String::with_capacity(value.len() + 16);
|
||||||
|
let chars = value.chars().collect::<Vec<_>>();
|
||||||
|
let mut index = 0;
|
||||||
|
let mut in_double_string = false;
|
||||||
|
let mut in_single_string = false;
|
||||||
|
let mut escaped = false;
|
||||||
|
let mut expects_key = false;
|
||||||
|
|
||||||
|
while index < chars.len() {
|
||||||
|
let ch = chars[index];
|
||||||
|
if in_double_string || in_single_string {
|
||||||
|
output.push(ch);
|
||||||
|
if escaped {
|
||||||
|
escaped = false;
|
||||||
|
} else if ch == '\\' {
|
||||||
|
escaped = true;
|
||||||
|
} else if in_double_string && ch == '"' {
|
||||||
|
in_double_string = false;
|
||||||
|
} else if in_single_string && ch == '\'' {
|
||||||
|
in_single_string = false;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match ch {
|
||||||
|
'"' => {
|
||||||
|
in_double_string = true;
|
||||||
|
output.push(ch);
|
||||||
|
expects_key = false;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
'\'' => {
|
||||||
|
in_single_string = true;
|
||||||
|
output.push(ch);
|
||||||
|
expects_key = false;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
'{' | ',' => {
|
||||||
|
expects_key = true;
|
||||||
|
output.push(ch);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
'}' | ']' => {
|
||||||
|
expects_key = false;
|
||||||
|
output.push(ch);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
ch if expects_key && ch.is_whitespace() => {
|
||||||
|
output.push(ch);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
ch if expects_key && is_identifier_start(ch) => {
|
||||||
|
let start = index;
|
||||||
|
index += 1;
|
||||||
|
while index < chars.len() && is_identifier_continue(chars[index]) {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
let mut lookahead = index;
|
||||||
|
while lookahead < chars.len() && chars[lookahead].is_whitespace() {
|
||||||
|
lookahead += 1;
|
||||||
|
}
|
||||||
|
if lookahead < chars.len() && chars[lookahead] == ':' {
|
||||||
|
output.push('"');
|
||||||
|
for key_ch in &chars[start..index] {
|
||||||
|
output.push(*key_ch);
|
||||||
|
}
|
||||||
|
output.push('"');
|
||||||
|
expects_key = false;
|
||||||
|
} else {
|
||||||
|
for key_ch in &chars[start..index] {
|
||||||
|
output.push(*key_ch);
|
||||||
|
}
|
||||||
|
expects_key = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
output.push(ch);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_single_quoted_strings(value: &str) -> String {
|
||||||
|
let mut output = String::with_capacity(value.len());
|
||||||
|
let mut chars = value.chars().peekable();
|
||||||
|
let mut in_double_string = false;
|
||||||
|
let mut in_single_string = false;
|
||||||
|
let mut escaped = false;
|
||||||
|
|
||||||
|
while let Some(ch) = chars.next() {
|
||||||
|
if in_double_string {
|
||||||
|
output.push(ch);
|
||||||
|
if escaped {
|
||||||
|
escaped = false;
|
||||||
|
} else if ch == '\\' {
|
||||||
|
escaped = true;
|
||||||
|
} else if ch == '"' {
|
||||||
|
in_double_string = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if in_single_string {
|
||||||
|
if escaped {
|
||||||
|
match ch {
|
||||||
|
'\'' => output.push('\''),
|
||||||
|
'"' => {
|
||||||
|
output.push('\\');
|
||||||
|
output.push('"');
|
||||||
|
}
|
||||||
|
'\\' => output.push('\\'),
|
||||||
|
_ => {
|
||||||
|
output.push('\\');
|
||||||
|
output.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
escaped = false;
|
||||||
|
} else if ch == '\\' {
|
||||||
|
escaped = true;
|
||||||
|
} else if ch == '\'' {
|
||||||
|
output.push('"');
|
||||||
|
in_single_string = false;
|
||||||
|
} else if ch == '"' {
|
||||||
|
output.push('\\');
|
||||||
|
output.push('"');
|
||||||
|
} else {
|
||||||
|
output.push(ch);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == '"' {
|
||||||
|
in_double_string = true;
|
||||||
|
output.push(ch);
|
||||||
|
} else if ch == '\'' {
|
||||||
|
in_single_string = true;
|
||||||
|
output.push('"');
|
||||||
|
} else {
|
||||||
|
output.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if in_single_string {
|
||||||
|
output.push('"');
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_missing_commas_between_fields(value: &str) -> Option<String> {
|
||||||
|
let mut output = String::with_capacity(value.len() + 8);
|
||||||
|
let chars = value.chars().collect::<Vec<_>>();
|
||||||
|
let mut index = 0;
|
||||||
|
let mut changed = false;
|
||||||
|
let mut in_string = false;
|
||||||
|
let mut escaped = false;
|
||||||
|
|
||||||
|
while index < chars.len() {
|
||||||
|
let ch = chars[index];
|
||||||
|
output.push(ch);
|
||||||
|
if in_string {
|
||||||
|
if escaped {
|
||||||
|
escaped = false;
|
||||||
|
} else if ch == '\\' {
|
||||||
|
escaped = true;
|
||||||
|
} else if ch == '"' {
|
||||||
|
in_string = false;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == '"' {
|
||||||
|
in_string = true;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(ch, '"' | '}' | ']' | '0'..='9' | 'e' | 'E' | 'l') {
|
||||||
|
let mut lookahead = index + 1;
|
||||||
|
while lookahead < chars.len() && chars[lookahead].is_whitespace() {
|
||||||
|
lookahead += 1;
|
||||||
|
}
|
||||||
|
if lookahead < chars.len()
|
||||||
|
&& chars[lookahead] == '"'
|
||||||
|
&& previous_non_whitespace(&chars, index) != Some(':')
|
||||||
|
{
|
||||||
|
output.push(',');
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
changed.then_some(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn previous_non_whitespace(chars: &[char], index: usize) -> Option<char> {
|
||||||
|
if index == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut cursor = index - 1;
|
||||||
|
loop {
|
||||||
|
if !chars[cursor].is_whitespace() {
|
||||||
|
return Some(chars[cursor]);
|
||||||
|
}
|
||||||
|
if cursor == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
cursor -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_identifier_start(ch: char) -> bool {
|
||||||
|
ch == '_' || ch == '$' || ch.is_ascii_alphabetic()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_identifier_continue(ch: char) -> bool {
|
||||||
|
is_identifier_start(ch) || ch.is_ascii_digit() || ch == '-' || ch == '.'
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repairs_partial_tool_arguments() {
|
||||||
|
assert_eq!(
|
||||||
|
repair_tool_arguments("{\"file_path\":\"main.tex\""),
|
||||||
|
"{\"file_path\":\"main.tex\"}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repairs_fenced_tool_arguments() {
|
||||||
|
assert_eq!(
|
||||||
|
repair_tool_arguments("```json\n{\"pattern\":\"FastVID\",}\n```"),
|
||||||
|
"{\"pattern\":\"FastVID\"}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repairs_json5_style_tool_arguments_like_ccr_enhancetool() {
|
||||||
|
let repaired: Value = serde_json::from_str(&repair_tool_arguments(
|
||||||
|
"{file_path:'main.tex', replace_all:false,}",
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
repaired,
|
||||||
|
json!({ "file_path": "main.tex", "replace_all": false })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repairs_commented_tool_arguments() {
|
||||||
|
let repaired: Value = serde_json::from_str(&repair_tool_arguments(
|
||||||
|
"{\n // target file\n file_path: 'main.tex',\n old_string: 'A',\n new_string: 'B',\n}",
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
repaired,
|
||||||
|
json!({ "file_path": "main.tex", "old_string": "A", "new_string": "B" })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repairs_mixed_quote_tool_arguments() {
|
||||||
|
let repaired: Value = serde_json::from_str(&repair_tool_arguments(
|
||||||
|
"{\"file_path\": 'main.tex', \"pattern\": 'FastVID'}",
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
repaired,
|
||||||
|
json!({ "file_path": "main.tex", "pattern": "FastVID" })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn falls_back_to_empty_object_for_unrepairable_arguments() {
|
||||||
|
assert_eq!(repair_tool_arguments("not json at all"), "{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_numeric_tool_call_ids() {
|
||||||
|
let id = normalized_tool_call_id(Some("123"));
|
||||||
|
|
||||||
|
assert!(id.starts_with("call_"));
|
||||||
|
assert_ne!(id, "123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_provider_tool_call_ids() {
|
||||||
|
assert_eq!(normalized_tool_call_id(Some("call_abc")), "call_abc");
|
||||||
|
}
|
||||||
|
}
|
||||||
148
apps/desktop/src-tauri/src/anthropic_proxy/transformers.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
use super::OpenAiProxyCredential;
|
||||||
|
|
||||||
|
const CLEANCACHE: &str = "cleancache";
|
||||||
|
const DEEPSEEK: &str = "deepseek";
|
||||||
|
const ENHANCETOOL: &str = "enhancetool";
|
||||||
|
const STREAMOPTIONS: &str = "streamoptions";
|
||||||
|
const TOOLUSE: &str = "tooluse";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub(super) struct ProxyTransformerChain {
|
||||||
|
names: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProxyTransformerChain {
|
||||||
|
pub(super) fn for_credential(credential: &OpenAiProxyCredential, wants_stream: bool) -> Self {
|
||||||
|
let mut chain = Self::default();
|
||||||
|
chain.push(CLEANCACHE);
|
||||||
|
if wants_stream {
|
||||||
|
chain.push(STREAMOPTIONS);
|
||||||
|
}
|
||||||
|
if is_deepseek_credential(credential) {
|
||||||
|
chain.push(DEEPSEEK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaudePrism already buffers and repairs tool-call arguments before
|
||||||
|
// returning them to Claude Code. Naming it here keeps the behavior
|
||||||
|
// traceable to Claude Code Router's enhancetool transformer.
|
||||||
|
chain.push(ENHANCETOOL);
|
||||||
|
for name in &credential.transformers {
|
||||||
|
chain.push(name);
|
||||||
|
}
|
||||||
|
for name in &credential.model_transformers {
|
||||||
|
chain.push(name);
|
||||||
|
}
|
||||||
|
for name in configured_transformer_names() {
|
||||||
|
chain.push(&name);
|
||||||
|
}
|
||||||
|
chain
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn has(&self, name: &str) -> bool {
|
||||||
|
self.names
|
||||||
|
.iter()
|
||||||
|
.any(|candidate| candidate.eq_ignore_ascii_case(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn has_tooluse(&self) -> bool {
|
||||||
|
self.has(TOOLUSE)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn has_cleancache(&self) -> bool {
|
||||||
|
self.has(CLEANCACHE)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn has_deepseek(&self) -> bool {
|
||||||
|
self.has(DEEPSEEK)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn has_streamoptions(&self) -> bool {
|
||||||
|
self.has(STREAMOPTIONS)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(super) fn from_names(names: &[&str]) -> Self {
|
||||||
|
let mut chain = Self::default();
|
||||||
|
for name in names {
|
||||||
|
chain.push(name);
|
||||||
|
}
|
||||||
|
chain
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&mut self, name: &str) {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() || self.has(name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.names.push(name.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configured_transformer_names() -> Vec<String> {
|
||||||
|
std::env::var("CLAUDE_PRISM_PROXY_TRANSFORMERS")
|
||||||
|
.ok()
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|value| {
|
||||||
|
value
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_deepseek_credential(credential: &OpenAiProxyCredential) -> bool {
|
||||||
|
let base_url = credential.base_url.to_ascii_lowercase();
|
||||||
|
let model = credential.model.to_ascii_lowercase();
|
||||||
|
base_url.contains("deepseek") || model.contains("deepseek")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn credential(base_url: &str, model: &str) -> OpenAiProxyCredential {
|
||||||
|
OpenAiProxyCredential {
|
||||||
|
api_key: "sk-test".to_string(),
|
||||||
|
base_url: base_url.to_string(),
|
||||||
|
model: model.to_string(),
|
||||||
|
transformers: Vec::new(),
|
||||||
|
model_transformers: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn includes_ccr_style_defaults_for_common_provider_adapters() {
|
||||||
|
let chain = ProxyTransformerChain::for_credential(
|
||||||
|
&credential("https://api.deepseek.com", "deepseek-chat"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(chain.has_cleancache());
|
||||||
|
assert!(chain.has_streamoptions());
|
||||||
|
assert!(chain.has_deepseek());
|
||||||
|
assert!(chain.has(ENHANCETOOL));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_enable_tooluse_unless_configured() {
|
||||||
|
let chain = ProxyTransformerChain::for_credential(
|
||||||
|
&credential("https://api.example.com/v1", "qwen"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!chain.has_tooluse());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_explicit_model_transformers() {
|
||||||
|
let mut credential = credential("https://api.example.com/v1", "qwen");
|
||||||
|
credential.model_transformers = vec!["tooluse".to_string()];
|
||||||
|
|
||||||
|
let chain = ProxyTransformerChain::for_credential(&credential, false);
|
||||||
|
|
||||||
|
assert!(chain.has_tooluse());
|
||||||
|
}
|
||||||
|
}
|
||||||
368
apps/desktop/src-tauri/src/claude_process.rs
Normal file
|
|
@ -0,0 +1,368 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tauri::{Emitter, Manager, WebviewWindow};
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::process::{Child, Command};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ClaudeProcessState {
|
||||||
|
pub processes: Arc<Mutex<HashMap<String, Child>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ClaudeProcessState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
processes: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, serde::Serialize)]
|
||||||
|
struct ClaudeOutputEvent {
|
||||||
|
tab_id: String,
|
||||||
|
data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, serde::Serialize)]
|
||||||
|
struct ClaudeCompleteEvent {
|
||||||
|
tab_id: String,
|
||||||
|
success: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, serde::Serialize)]
|
||||||
|
struct ClaudeErrorEvent {
|
||||||
|
tab_id: String,
|
||||||
|
data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SpawnProviderMetadata {
|
||||||
|
pub provider: &'static str,
|
||||||
|
pub provider_credential_id: String,
|
||||||
|
pub model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum ClaudeStopMode {
|
||||||
|
/// User pressed Stop; terminate the run immediately.
|
||||||
|
Terminate,
|
||||||
|
/// User wants to guide the next turn; prefer a graceful interrupt so
|
||||||
|
/// Claude Code can persist session state before the frontend resumes it.
|
||||||
|
Interrupt,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_key(window_label: &str, tab_id: &str) -> String {
|
||||||
|
format!("{}:{}", window_label, tab_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the Claude CLI process and stream output via Tauri events.
|
||||||
|
/// Events are emitted only to the originating window, tagged with tab_id.
|
||||||
|
pub async fn spawn_claude_process(
|
||||||
|
window: WebviewWindow,
|
||||||
|
mut cmd: Command,
|
||||||
|
tab_id: String,
|
||||||
|
stdin_payload: Option<String>,
|
||||||
|
provider_metadata: Option<SpawnProviderMetadata>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let window_label = window.label().to_string();
|
||||||
|
let process_key = process_key(&window_label, &tab_id);
|
||||||
|
|
||||||
|
if stdin_payload.is_some() {
|
||||||
|
cmd.stdin(std::process::Stdio::piped());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut child = cmd.spawn().map_err(|e| {
|
||||||
|
eprintln!(
|
||||||
|
"[claude-spawn] Failed to spawn process for tab {}: {}",
|
||||||
|
tab_id, e
|
||||||
|
);
|
||||||
|
format!(
|
||||||
|
"Failed to spawn Claude process: {}. Is Claude Code CLI installed?",
|
||||||
|
e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Some(payload) = stdin_payload {
|
||||||
|
let mut stdin = child
|
||||||
|
.stdin
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| "Failed to acquire stdin for Claude process".to_string())?;
|
||||||
|
stdin
|
||||||
|
.write_all(payload.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to write prompt to Claude process stdin: {}", e))?;
|
||||||
|
stdin
|
||||||
|
.shutdown()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to close Claude process stdin: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = child.stdout.take().ok_or("Failed to capture stdout")?;
|
||||||
|
let stderr = child.stderr.take().ok_or("Failed to capture stderr")?;
|
||||||
|
|
||||||
|
let process_arc = window
|
||||||
|
.state::<ClaudeProcessState>()
|
||||||
|
.inner()
|
||||||
|
.processes
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut processes = process_arc.lock().await;
|
||||||
|
if let Some(mut existing) = processes.remove(&process_key) {
|
||||||
|
let _ = existing.kill().await;
|
||||||
|
}
|
||||||
|
processes.insert(process_key.clone(), child);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout_reader = BufReader::new(stdout);
|
||||||
|
let stderr_reader = BufReader::new(stderr);
|
||||||
|
let result_success_holder: Arc<std::sync::Mutex<Option<bool>>> =
|
||||||
|
Arc::new(std::sync::Mutex::new(None));
|
||||||
|
|
||||||
|
let start_time = std::time::Instant::now();
|
||||||
|
|
||||||
|
let win_stdout = window.clone();
|
||||||
|
let result_success_stdout = result_success_holder.clone();
|
||||||
|
let tab_id_stdout = tab_id.clone();
|
||||||
|
let provider_metadata_stdout = provider_metadata.clone();
|
||||||
|
let stdout_task = tokio::spawn(async move {
|
||||||
|
let mut lines = stdout_reader.lines();
|
||||||
|
let mut line_count: u64 = 0;
|
||||||
|
while let Ok(Some(mut line)) = lines.next_line().await {
|
||||||
|
line_count += 1;
|
||||||
|
let elapsed = start_time.elapsed().as_secs_f64();
|
||||||
|
|
||||||
|
if let Ok(mut msg) = serde_json::from_str::<serde_json::Value>(&line) {
|
||||||
|
let msg_type = msg.get("type").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
let msg_sub = msg.get("subtype").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
eprintln!(
|
||||||
|
"[claude-stdout] [{}] +{:.1}s #{} type={} sub={} len={}",
|
||||||
|
tab_id_stdout,
|
||||||
|
elapsed,
|
||||||
|
line_count,
|
||||||
|
msg_type,
|
||||||
|
msg_sub,
|
||||||
|
line.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
if msg.get("type").and_then(|v| v.as_str()) == Some("system")
|
||||||
|
&& msg.get("subtype").and_then(|v| v.as_str()) == Some("init")
|
||||||
|
{
|
||||||
|
if let Some(metadata) = provider_metadata_stdout.as_ref() {
|
||||||
|
if let Some(object) = msg.as_object_mut() {
|
||||||
|
object.insert(
|
||||||
|
"provider".to_string(),
|
||||||
|
serde_json::Value::String(metadata.provider.to_string()),
|
||||||
|
);
|
||||||
|
object.insert(
|
||||||
|
"provider_credential_id".to_string(),
|
||||||
|
serde_json::Value::String(metadata.provider_credential_id.clone()),
|
||||||
|
);
|
||||||
|
object.insert(
|
||||||
|
"model".to_string(),
|
||||||
|
serde_json::Value::String(metadata.model.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
line = msg.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.get("type").and_then(|v| v.as_str()) == Some("result") {
|
||||||
|
let is_success = msg.get("subtype").and_then(|v| v.as_str()) == Some("success");
|
||||||
|
if let Ok(mut guard) = result_success_stdout.lock() {
|
||||||
|
*guard = Some(is_success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = win_stdout.emit(
|
||||||
|
"claude-output",
|
||||||
|
ClaudeOutputEvent {
|
||||||
|
tab_id: tab_id_stdout.clone(),
|
||||||
|
data: line,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
eprintln!(
|
||||||
|
"[claude-stdout] [{}] stream ended after {} lines ({:.1}s)",
|
||||||
|
tab_id_stdout,
|
||||||
|
line_count,
|
||||||
|
start_time.elapsed().as_secs_f64()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let win_stderr = window.clone();
|
||||||
|
let tab_id_stderr = tab_id.clone();
|
||||||
|
let stderr_task = tokio::spawn(async move {
|
||||||
|
let mut lines = stderr_reader.lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
eprintln!(
|
||||||
|
"[claude-stderr] [{}] +{:.1}s {}",
|
||||||
|
tab_id_stderr,
|
||||||
|
start_time.elapsed().as_secs_f64(),
|
||||||
|
&line[..line.len().min(200)]
|
||||||
|
);
|
||||||
|
let _ = win_stderr.emit(
|
||||||
|
"claude-error",
|
||||||
|
ClaudeErrorEvent {
|
||||||
|
tab_id: tab_id_stderr.clone(),
|
||||||
|
data: line,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let process_arc_wait = process_arc.clone();
|
||||||
|
let win_wait = window;
|
||||||
|
let process_key_wait = process_key;
|
||||||
|
let tab_id_wait = tab_id;
|
||||||
|
let result_success_wait = result_success_holder.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = stdout_task.await;
|
||||||
|
let _ = stderr_task.await;
|
||||||
|
|
||||||
|
let mut processes = process_arc_wait.lock().await;
|
||||||
|
let success = if let Some(mut child) = processes.remove(&process_key_wait) {
|
||||||
|
match child.wait().await {
|
||||||
|
Ok(status) => {
|
||||||
|
let exit_success = status.success();
|
||||||
|
let result_success = result_success_wait.lock().ok().and_then(|guard| *guard);
|
||||||
|
let success = exit_success || result_success == Some(true);
|
||||||
|
eprintln!(
|
||||||
|
"[claude-process] [{}] exited with status={} result_success={:?} final_success={} ({:.1}s)",
|
||||||
|
tab_id_wait,
|
||||||
|
status,
|
||||||
|
result_success,
|
||||||
|
success,
|
||||||
|
start_time.elapsed().as_secs_f64()
|
||||||
|
);
|
||||||
|
success
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"[claude-process] [{}] wait error: {} ({:.1}s)",
|
||||||
|
tab_id_wait,
|
||||||
|
e,
|
||||||
|
start_time.elapsed().as_secs_f64()
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"[claude-process] [{}] no child found in map ({:.1}s)",
|
||||||
|
tab_id_wait,
|
||||||
|
start_time.elapsed().as_secs_f64()
|
||||||
|
);
|
||||||
|
false
|
||||||
|
};
|
||||||
|
drop(processes);
|
||||||
|
|
||||||
|
let _ = win_wait.emit(
|
||||||
|
"claude-complete",
|
||||||
|
ClaudeCompleteEvent {
|
||||||
|
tab_id: tab_id_wait,
|
||||||
|
success,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop_claude_process(
|
||||||
|
window: WebviewWindow,
|
||||||
|
tab_id: String,
|
||||||
|
mode: ClaudeStopMode,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let window_label = window.label().to_string();
|
||||||
|
let process_key = process_key(&window_label, &tab_id);
|
||||||
|
let claude_state = window.state::<ClaudeProcessState>();
|
||||||
|
let mut processes = claude_state.processes.lock().await;
|
||||||
|
if let Some(mut child) = processes.remove(&process_key) {
|
||||||
|
drop(processes);
|
||||||
|
let stopped = match mode {
|
||||||
|
ClaudeStopMode::Terminate => {
|
||||||
|
terminate_process_tree(&mut child).await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
ClaudeStopMode::Interrupt => interrupt_or_terminate(&mut child).await,
|
||||||
|
};
|
||||||
|
return Ok(stopped);
|
||||||
|
}
|
||||||
|
drop(processes);
|
||||||
|
|
||||||
|
let _ = window.emit(
|
||||||
|
"claude-complete",
|
||||||
|
ClaudeCompleteEvent {
|
||||||
|
tab_id,
|
||||||
|
success: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
async fn interrupt_or_terminate(child: &mut Child) -> bool {
|
||||||
|
if let Some(pid) = child.id() {
|
||||||
|
let status = tokio::process::Command::new("kill")
|
||||||
|
.arg("-INT")
|
||||||
|
.arg(pid.to_string())
|
||||||
|
.status()
|
||||||
|
.await;
|
||||||
|
if matches!(status, Ok(status) if status.success()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
terminate_process_tree(child).await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
async fn interrupt_or_terminate(child: &mut Child) -> bool {
|
||||||
|
// Windows GUI processes do not have a reliable console-control path from
|
||||||
|
// Tauri without a PTY/ConPTY session. For guided follow-ups, fall back to
|
||||||
|
// terminating the current run so the frontend can immediately continue the
|
||||||
|
// same tab with the queued guidance.
|
||||||
|
terminate_process_tree(child).await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
async fn terminate_process_tree(child: &mut Child) {
|
||||||
|
if let Some(pid) = child.id() {
|
||||||
|
let _ = Command::new("taskkill")
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.args(["/PID", &pid.to_string(), "/T", "/F"])
|
||||||
|
.status()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
let _ = child.start_kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
async fn terminate_process_tree(child: &mut Child) {
|
||||||
|
let _ = child.start_kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kill all Claude processes associated with a specific window label.
|
||||||
|
/// Called when a window is destroyed.
|
||||||
|
pub async fn kill_process_for_window(state: &ClaudeProcessState, window_label: &str) {
|
||||||
|
let mut processes = state.processes.lock().await;
|
||||||
|
let prefix = format!("{}:", window_label);
|
||||||
|
let keys_to_remove: Vec<String> = processes
|
||||||
|
.keys()
|
||||||
|
.filter(|k| k.starts_with(&prefix))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
for key in keys_to_remove {
|
||||||
|
if let Some(mut child) = processes.remove(&key) {
|
||||||
|
let _ = child.kill().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,14 @@ use tokio::sync::{Mutex, Semaphore};
|
||||||
|
|
||||||
const MAX_CONCURRENT: usize = 3;
|
const MAX_CONCURRENT: usize = 3;
|
||||||
|
|
||||||
|
/// Windows CREATE_NO_WINDOW flag to prevent console windows from flashing
|
||||||
|
/// when spawning TeXLive/Tectonic child processes from the GUI app.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
|
||||||
struct BuildInfo {
|
struct BuildInfo {
|
||||||
work_dir: PathBuf,
|
work_dir: PathBuf,
|
||||||
main_file_name: String,
|
main_file_name: String,
|
||||||
|
|
@ -43,18 +51,34 @@ fn extract_error_lines(log: &str) -> String {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract real errors first — they take priority over "No pages of output"
|
let lines: Vec<&str> = log.lines().collect();
|
||||||
let error_lines: Vec<&str> = log
|
|
||||||
.lines()
|
|
||||||
.filter(|l| l.starts_with('!') || l.contains("Error:") || l.contains("error:"))
|
|
||||||
.take(10)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if !error_lines.is_empty() {
|
let mut blocks: Vec<String> = Vec::new();
|
||||||
return error_lines.join("\n");
|
let mut i = 0;
|
||||||
|
while i < lines.len() && blocks.len() < 5 {
|
||||||
|
let line = lines[i];
|
||||||
|
let is_error_start =
|
||||||
|
line.starts_with('!') || line.contains("Error:") || line.contains("error:");
|
||||||
|
|
||||||
|
if is_error_start {
|
||||||
|
let end = (i + 14).min(lines.len());
|
||||||
|
blocks.push(lines[i..end].join("\n"));
|
||||||
|
i = end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
i += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if log.lines().any(|l| l.contains("No pages of output")) {
|
if !blocks.is_empty() {
|
||||||
|
let mut result = blocks.join("\n\n");
|
||||||
|
result.push_str("\n\n---- Engine output ----\n");
|
||||||
|
let tail_start = lines.len().saturating_sub(20);
|
||||||
|
result.push_str(&lines[tail_start..].join("\n"));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if lines.iter().any(|l| l.contains("No pages of output")) {
|
||||||
return "No pages of output. Add visible content to the document body.".to_string();
|
return "No pages of output. Add visible content to the document body.".to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,6 +126,102 @@ fn detect_tex_engine(content: &str) -> Option<TexEngine> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
enum BibTool {
|
||||||
|
Biber,
|
||||||
|
BibTeX,
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect which bibliography tool is needed by scanning .tex content.
|
||||||
|
fn detect_bib_tool(content: &str) -> BibTool {
|
||||||
|
for line in content.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with('%') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if trimmed.contains("\\usepackage") && trimmed.contains("biblatex") {
|
||||||
|
return BibTool::Biber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for line in content.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with('%') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if trimmed.contains("\\bibliography{") || trimmed.contains("\\addbibresource{") {
|
||||||
|
return BibTool::BibTeX;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BibTool::None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a TeXLive engine binary to its full path.
|
||||||
|
/// GUI apps on macOS lack the user's shell PATH, so we check standard
|
||||||
|
/// TeXLive installation locations and fall back to a login-shell query.
|
||||||
|
fn find_texlive_binary(name: &str) -> Result<PathBuf, String> {
|
||||||
|
// 1. Try PATH (works when launched from terminal)
|
||||||
|
if let Ok(path) = which::which(name) {
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check standard TeXLive locations
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
let standard_paths = [
|
||||||
|
format!("/Library/TeX/texbin/{}", name),
|
||||||
|
format!("/usr/local/texlive/2025/bin/universal-darwin/{}", name),
|
||||||
|
format!("/usr/local/texlive/2024/bin/universal-darwin/{}", name),
|
||||||
|
format!("/usr/local/texlive/2025/bin/x86_64-linux/{}", name),
|
||||||
|
format!("/usr/local/texlive/2024/bin/x86_64-linux/{}", name),
|
||||||
|
format!("/opt/homebrew/bin/{}", name),
|
||||||
|
format!("/usr/bin/{}", name),
|
||||||
|
];
|
||||||
|
for path_str in &standard_paths {
|
||||||
|
let p = PathBuf::from(path_str);
|
||||||
|
if p.exists() {
|
||||||
|
return Ok(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let standard_paths = [
|
||||||
|
format!("C:\\texlive\\2025\\bin\\windows\\{}.exe", name),
|
||||||
|
format!("C:\\texlive\\2024\\bin\\windows\\{}.exe", name),
|
||||||
|
];
|
||||||
|
for path_str in &standard_paths {
|
||||||
|
let p = PathBuf::from(path_str);
|
||||||
|
if p.exists() {
|
||||||
|
return Ok(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. macOS: ask login shell for PATH
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
if let Ok(output) = std::process::Command::new("/bin/zsh")
|
||||||
|
.args(["-l", "-c", &format!("which {}", name)])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
if output.status.success() {
|
||||||
|
let resolved = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let p = PathBuf::from(&resolved);
|
||||||
|
if p.exists() {
|
||||||
|
return Ok(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!(
|
||||||
|
"{} not found. Install TeXLive or add it to your PATH.",
|
||||||
|
name
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
|
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||||
if !dst.exists() {
|
if !dst.exists() {
|
||||||
std::fs::create_dir_all(dst)?;
|
std::fs::create_dir_all(dst)?;
|
||||||
|
|
@ -134,10 +254,12 @@ fn sync_source_files(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||||
for entry in std::fs::read_dir(src)? {
|
for entry in std::fs::read_dir(src)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
let src_path = entry.path();
|
let src_path = entry.path();
|
||||||
let dst_path = dst.join(entry.file_name());
|
let file_name = entry.file_name();
|
||||||
|
let dst_path = dst.join(&file_name);
|
||||||
if src_path.is_dir() {
|
if src_path.is_dir() {
|
||||||
let name = entry.file_name();
|
let name = file_name.to_string_lossy();
|
||||||
if name.to_string_lossy().starts_with('.') {
|
if name.starts_with('.') || matches!(name.as_ref(), "node_modules" | "target" | "dist")
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
sync_source_files(&src_path, &dst_path)?;
|
sync_source_files(&src_path, &dst_path)?;
|
||||||
|
|
@ -173,6 +295,21 @@ fn sync_source_files(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||||
// Cloud storage (Dropbox/iCloud) may keep files as online-only
|
// Cloud storage (Dropbox/iCloud) may keep files as online-only
|
||||||
// placeholders with 0 bytes. Reading the file forces a download.
|
// placeholders with 0 bytes. Reading the file forces a download.
|
||||||
let metadata = std::fs::metadata(&src_path)?;
|
let metadata = std::fs::metadata(&src_path)?;
|
||||||
|
|
||||||
|
if metadata.len() > 0 {
|
||||||
|
if let Ok(dst_meta) = std::fs::metadata(&dst_path) {
|
||||||
|
if metadata.len() == dst_meta.len() {
|
||||||
|
if let (Ok(src_m), Ok(dst_m)) =
|
||||||
|
(metadata.modified(), dst_meta.modified())
|
||||||
|
{
|
||||||
|
if src_m == dst_m {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if metadata.len() == 0 {
|
if metadata.len() == 0 {
|
||||||
// Attempt to materialize the file by reading it
|
// Attempt to materialize the file by reading it
|
||||||
let data = std::fs::read(&src_path)?;
|
let data = std::fs::read(&src_path)?;
|
||||||
|
|
@ -220,7 +357,7 @@ fn lower_thread_priority() {
|
||||||
|
|
||||||
// --- Tectonic Compilation ---
|
// --- Tectonic Compilation ---
|
||||||
|
|
||||||
fn compile_with_tectonic(work_dir: &Path, main_file: &str) -> Result<(), String> {
|
pub(crate) fn compile_with_tectonic(work_dir: &Path, main_file: &str) -> Result<(), String> {
|
||||||
use tectonic::config::PersistentConfig;
|
use tectonic::config::PersistentConfig;
|
||||||
use tectonic::driver::{OutputFormat, PassSetting, ProcessingSessionBuilder};
|
use tectonic::driver::{OutputFormat, PassSetting, ProcessingSessionBuilder};
|
||||||
use tectonic::status::NoopStatusBackend;
|
use tectonic::status::NoopStatusBackend;
|
||||||
|
|
@ -265,6 +402,225 @@ fn compile_with_tectonic(work_dir: &Path, main_file: &str) -> Result<(), String>
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run tectonic compilation in an isolated subprocess.
|
||||||
|
///
|
||||||
|
/// This avoids the font cache assertion failure (`font_cache.fonts == NULL`)
|
||||||
|
/// that occurs when tectonic is called multiple times in the same process.
|
||||||
|
/// The C-level static `font_cache` in `dpx-pdffont.c` is not cleaned up
|
||||||
|
/// on compilation failure, causing subsequent calls to abort.
|
||||||
|
///
|
||||||
|
/// By spawning a subprocess, each compilation gets a fresh process with
|
||||||
|
/// clean global state, and cleanup happens automatically on process exit.
|
||||||
|
fn compile_with_tectonic_subprocess(work_dir: &Path, main_file: &str) -> Result<(), String> {
|
||||||
|
let exe = std::env::current_exe()
|
||||||
|
.map_err(|e| format!("Failed to get current executable path: {}", e))?;
|
||||||
|
|
||||||
|
let mut cmd = std::process::Command::new(&exe);
|
||||||
|
cmd.args(["--tectonic-compile", &work_dir.to_string_lossy(), main_file])
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped());
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
let output = cmd
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to spawn tectonic subprocess: {}", e))?;
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
Err(stderr.trim().to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TeXLive Compilation ---
|
||||||
|
|
||||||
|
/// Build a PATH that includes the TeXLive bin directory so that xelatex
|
||||||
|
/// can find xdvipdfmx, kpsewhich, and other tools it invokes internally.
|
||||||
|
/// GUI apps on macOS have a minimal PATH that doesn't include TeXLive.
|
||||||
|
fn texlive_env_path(engine: &Path) -> String {
|
||||||
|
let texbin = engine
|
||||||
|
.parent()
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let current_path = std::env::var("PATH").unwrap_or_default();
|
||||||
|
if current_path.contains(&texbin) {
|
||||||
|
current_path
|
||||||
|
} else {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
format!("{};{}", texbin, current_path)
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
format!("{}:{}", texbin, current_path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a single TeX engine pass. Never returns `Err` for a non-zero exit
|
||||||
|
/// code — TeXLive returns non-zero for warnings, font substitutions, etc.
|
||||||
|
/// The only `Err` is when the process cannot be *spawned* at all.
|
||||||
|
/// The caller decides success by checking whether the PDF was produced.
|
||||||
|
fn run_texlive_pass(
|
||||||
|
engine: &Path,
|
||||||
|
args: &[&str],
|
||||||
|
main_file: &Path,
|
||||||
|
work_dir: &Path,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut cmd = std::process::Command::new(engine);
|
||||||
|
cmd.args(args)
|
||||||
|
.arg(main_file)
|
||||||
|
.current_dir(work_dir)
|
||||||
|
.env("PATH", texlive_env_path(engine))
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped());
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
let output = cmd
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to launch {}: {}", engine.display(), e))?;
|
||||||
|
|
||||||
|
// TeXLive returns non-zero on warnings too — don't fail here.
|
||||||
|
// The caller decides success by checking whether the PDF was produced.
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
if !stderr.trim().is_empty() {
|
||||||
|
eprintln!("[texlive] engine stderr: {}", stderr.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_with_texlive(
|
||||||
|
work_dir: &Path,
|
||||||
|
main_file: &str,
|
||||||
|
engine: Option<TexEngine>,
|
||||||
|
tex_content: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let engine_name = match engine {
|
||||||
|
Some(TexEngine::XeLaTeX) | None => "xelatex",
|
||||||
|
Some(TexEngine::Latex) => "pdflatex",
|
||||||
|
Some(TexEngine::LuaLaTeX) => "lualatex",
|
||||||
|
};
|
||||||
|
|
||||||
|
let engine_path = find_texlive_binary(engine_name)?;
|
||||||
|
let env_path = texlive_env_path(&engine_path);
|
||||||
|
eprintln!(
|
||||||
|
"[texlive] backend: {} ({})",
|
||||||
|
engine_name,
|
||||||
|
engine_path.display()
|
||||||
|
);
|
||||||
|
let bib_tool = detect_bib_tool(tex_content);
|
||||||
|
|
||||||
|
// Use "." as output-directory since current_dir is already work_dir.
|
||||||
|
// Absolute paths break when they contain ~ (e.g. iCloud's com~apple~CloudDocs)
|
||||||
|
// because TeX interprets ~ as a home directory shortcut.
|
||||||
|
let output_dir_arg = "-output-directory=.".to_string();
|
||||||
|
// Do NOT use -halt-on-error: xelatex is a pipeline (xetex → .xdv → xdvipdfmx → .pdf).
|
||||||
|
// With -halt-on-error, recoverable warnings (e.g. missing font shapes) cause xetex to
|
||||||
|
// exit non-zero, and the xelatex wrapper skips the xdvipdfmx step — producing .xdv but
|
||||||
|
// no .pdf. -interaction=nonstopmode alone is sufficient to avoid interactive prompts.
|
||||||
|
let common_args: Vec<&str> = vec!["-synctex=1", "-interaction=nonstopmode", &output_dir_arg];
|
||||||
|
|
||||||
|
let main_file_path = Path::new(main_file);
|
||||||
|
|
||||||
|
// Pass 1
|
||||||
|
run_texlive_pass(&engine_path, &common_args, main_file_path, work_dir)?;
|
||||||
|
|
||||||
|
// Bib pass (if needed)
|
||||||
|
let main_stem = Path::new(main_file)
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("document");
|
||||||
|
|
||||||
|
match bib_tool {
|
||||||
|
BibTool::Biber => {
|
||||||
|
let biber_path = find_texlive_binary("biber")?;
|
||||||
|
let mut cmd = std::process::Command::new(&biber_path);
|
||||||
|
cmd.arg(main_stem)
|
||||||
|
.current_dir(work_dir)
|
||||||
|
.env("PATH", &env_path)
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped());
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
let output = cmd
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to run biber: {}", e))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
eprintln!(
|
||||||
|
"[texlive] biber warning: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BibTool::BibTeX => {
|
||||||
|
let bibtex_path = find_texlive_binary("bibtex")?;
|
||||||
|
let aux_file = work_dir.join(format!("{}.aux", main_stem));
|
||||||
|
let mut cmd = std::process::Command::new(&bibtex_path);
|
||||||
|
cmd.arg(&aux_file)
|
||||||
|
.current_dir(work_dir)
|
||||||
|
.env("PATH", &env_path)
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped());
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
let output = cmd
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to run bibtex: {}", e))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
eprintln!(
|
||||||
|
"[texlive] bibtex warning: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BibTool::None => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: resolve references / TOC
|
||||||
|
run_texlive_pass(&engine_path, &common_args, &main_file_path, work_dir)?;
|
||||||
|
|
||||||
|
// Pass 3: stabilize citations (only if bib was used)
|
||||||
|
if !matches!(bib_tool, BibTool::None) {
|
||||||
|
run_texlive_pass(&engine_path, &common_args, &main_file_path, work_dir)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pdf_path = work_dir.join(format!("{}.pdf", main_stem));
|
||||||
|
let xdv_path = work_dir.join(format!("{}.xdv", main_stem));
|
||||||
|
|
||||||
|
// Fallback: if xelatex produced .xdv but no .pdf (e.g. xdvipdfmx was skipped due to
|
||||||
|
// warnings), manually run xdvipdfmx to convert .xdv → .pdf.
|
||||||
|
if !pdf_path.exists() && xdv_path.exists() {
|
||||||
|
eprintln!("[texlive] .xdv exists but no .pdf — running xdvipdfmx manually");
|
||||||
|
if let Ok(xdvipdfmx) = find_texlive_binary("xdvipdfmx") {
|
||||||
|
let mut cmd = std::process::Command::new(&xdvipdfmx);
|
||||||
|
cmd.args(["-o", &pdf_path.to_string_lossy()])
|
||||||
|
.arg(&xdv_path)
|
||||||
|
.current_dir(work_dir)
|
||||||
|
.env("PATH", &env_path)
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped());
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
let output = cmd
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to launch xdvipdfmx: {}", e))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
if !stderr.trim().is_empty() {
|
||||||
|
eprintln!("[texlive] xdvipdfmx stderr: {}", stderr.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success is determined by whether the PDF exists, not by exit codes.
|
||||||
|
// The caller (compile_latex) checks pdf_path.exists() and reads the log for errors.
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// --- SyncTeX Native Parser ---
|
// --- SyncTeX Native Parser ---
|
||||||
|
|
||||||
struct SynctexNode {
|
struct SynctexNode {
|
||||||
|
|
@ -407,11 +763,50 @@ fn parse_synctex_node(s: &str, factor: f64, x_offset: f64, y_offset: f64) -> Opt
|
||||||
|
|
||||||
// --- Tauri Commands ---
|
// --- Tauri Commands ---
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct TexliveStatus {
|
||||||
|
pub available: bool,
|
||||||
|
pub engines: Vec<String>,
|
||||||
|
pub version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn detect_texlive() -> TexliveStatus {
|
||||||
|
let engines_to_check = ["pdflatex", "xelatex", "lualatex"];
|
||||||
|
let mut found_engines = Vec::new();
|
||||||
|
|
||||||
|
for name in &engines_to_check {
|
||||||
|
if find_texlive_binary(name).is_ok() {
|
||||||
|
found_engines.push(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let version = find_texlive_binary("pdflatex").ok().and_then(|path| {
|
||||||
|
let mut cmd = std::process::Command::new(&path);
|
||||||
|
cmd.arg("--version")
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped());
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
cmd.output().ok().and_then(|o| {
|
||||||
|
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||||
|
stdout.lines().next().map(|l| l.to_string())
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
TexliveStatus {
|
||||||
|
available: !found_engines.is_empty(),
|
||||||
|
engines: found_engines,
|
||||||
|
version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn compile_latex(
|
pub async fn compile_latex(
|
||||||
state: tauri::State<'_, LatexCompilerState>,
|
state: tauri::State<'_, LatexCompilerState>,
|
||||||
project_dir: String,
|
project_dir: String,
|
||||||
main_file: String,
|
main_file: String,
|
||||||
|
use_texlive: Option<bool>,
|
||||||
) -> Result<tauri::ipc::Response, String> {
|
) -> Result<tauri::ipc::Response, String> {
|
||||||
// Acquire semaphore permit (non-blocking)
|
// Acquire semaphore permit (non-blocking)
|
||||||
let _permit = state
|
let _permit = state
|
||||||
|
|
@ -431,6 +826,7 @@ pub async fn compile_latex(
|
||||||
let _project_guard = project_lock.lock().await;
|
let _project_guard = project_lock.lock().await;
|
||||||
|
|
||||||
let t0 = std::time::Instant::now();
|
let t0 = std::time::Instant::now();
|
||||||
|
let use_texlive = use_texlive.unwrap_or(false);
|
||||||
|
|
||||||
let main_file_name = Path::new(&main_file)
|
let main_file_name = Path::new(&main_file)
|
||||||
.file_stem()
|
.file_stem()
|
||||||
|
|
@ -461,14 +857,15 @@ pub async fn compile_latex(
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[latex] +{:.0}ms {} ({})",
|
"[latex] +{:.0}ms {} ({}, backend={})",
|
||||||
t0.elapsed().as_millis(),
|
t0.elapsed().as_millis(),
|
||||||
if is_reuse {
|
if is_reuse {
|
||||||
"sync source files"
|
"sync source files"
|
||||||
} else {
|
} else {
|
||||||
"full copy"
|
"full copy"
|
||||||
},
|
},
|
||||||
if is_reuse { "reuse" } else { "first build" }
|
if is_reuse { "reuse" } else { "first build" },
|
||||||
|
if use_texlive { "texlive" } else { "tectonic" }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove stale PDF so a failed compile doesn't return the previous result.
|
// Remove stale PDF so a failed compile doesn't return the previous result.
|
||||||
|
|
@ -485,41 +882,70 @@ pub async fn compile_latex(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect TeX engine from magic comment
|
// Detect TeX engine from magic comment
|
||||||
if let Ok(content) = std::fs::read_to_string(&main_tex_path) {
|
let main_tex_content = std::fs::read_to_string(&main_tex_path).unwrap_or_default();
|
||||||
if let Some(engine) = detect_tex_engine(&content) {
|
let engine = detect_tex_engine(&main_tex_content);
|
||||||
if engine == TexEngine::LuaLaTeX {
|
|
||||||
return Err(
|
// Save engine name before `engine` is moved into the spawn_blocking closure
|
||||||
"Compilation failed\n\nThis document requires LuaLaTeX (% !TEX program = lualatex), \
|
let engine_name_for_label = match &engine {
|
||||||
which is not supported. Prism uses a XeTeX-based engine (Tectonic). \
|
Some(TexEngine::XeLaTeX) | None => "xelatex",
|
||||||
Please switch to XeLaTeX or remove the magic comment."
|
Some(TexEngine::Latex) => "pdflatex",
|
||||||
.to_string(),
|
Some(TexEngine::LuaLaTeX) => "lualatex",
|
||||||
);
|
};
|
||||||
}
|
let backend_label = if use_texlive {
|
||||||
// XeLaTeX → native (Tectonic is XeTeX-based), pdflatex → mostly compatible
|
format!("TeXLive/{}", engine_name_for_label)
|
||||||
|
} else {
|
||||||
|
"Tectonic".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
if !use_texlive {
|
||||||
|
if let Some(TexEngine::LuaLaTeX) = engine {
|
||||||
|
return Err(
|
||||||
|
"Compilation failed\n\nThis document requires LuaLaTeX (% !TEX program = lualatex), \
|
||||||
|
which is not supported. Prism uses a XeTeX-based engine (Tectonic). \
|
||||||
|
Please switch to XeLaTeX or remove the magic comment."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run Tectonic in a blocking task with reduced priority so the UI stays responsive.
|
let compile_result = if use_texlive {
|
||||||
let work_dir_clone = work_dir.clone();
|
let work_dir_clone = work_dir.clone();
|
||||||
let main_file_clone = main_file.clone();
|
let main_file_clone = main_file.clone();
|
||||||
let compile_result = tokio::task::spawn_blocking(move || {
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
lower_thread_priority();
|
lower_thread_priority();
|
||||||
compile_with_tectonic(&work_dir_clone, &main_file_clone)
|
compile_with_texlive(&work_dir_clone, &main_file_clone, engine, &main_tex_content)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Compilation task panicked: {}", e))?;
|
.map_err(|e| format!("Compilation task panicked: {}", e))?;
|
||||||
|
eprintln!(
|
||||||
eprintln!(
|
"[latex] +{:.0}ms texlive done (ok={})",
|
||||||
"[latex] +{:.0}ms tectonic done (ok={})",
|
t0.elapsed().as_millis(),
|
||||||
t0.elapsed().as_millis(),
|
result.is_ok()
|
||||||
compile_result.is_ok()
|
);
|
||||||
);
|
result
|
||||||
|
} else {
|
||||||
|
// Run Tectonic in a subprocess to isolate C-level global state (font cache, etc.).
|
||||||
|
let work_dir_clone = work_dir.clone();
|
||||||
|
let main_file_clone = main_file.clone();
|
||||||
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
|
lower_thread_priority();
|
||||||
|
compile_with_tectonic_subprocess(&work_dir_clone, &main_file_clone)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Compilation task panicked: {}", e))?;
|
||||||
|
eprintln!(
|
||||||
|
"[latex] +{:.0}ms tectonic done (ok={})",
|
||||||
|
t0.elapsed().as_millis(),
|
||||||
|
result.is_ok()
|
||||||
|
);
|
||||||
|
result
|
||||||
|
};
|
||||||
|
|
||||||
let log_path = work_dir.join(format!("{}.log", main_file_name));
|
let log_path = work_dir.join(format!("{}.log", main_file_name));
|
||||||
|
|
||||||
// Handle "No pages of output" — retry with \AtEndDocument{\null} injection
|
// Handle "No pages of output" — retry with \AtEndDocument{\null} injection (Tectonic only).
|
||||||
// Skip retry if there are real errors (e.g. missing packages) — retrying won't help.
|
// TeXLive multi-pass handles this differently; the injection is Tectonic-specific.
|
||||||
if !pdf_path.exists() {
|
if !use_texlive && !pdf_path.exists() {
|
||||||
let log_path_clone = log_path.clone();
|
let log_path_clone = log_path.clone();
|
||||||
let main_tex = work_dir.join(&main_file);
|
let main_tex = work_dir.join(&main_file);
|
||||||
let pdf_path_clone = pdf_path.clone();
|
let pdf_path_clone = pdf_path.clone();
|
||||||
|
|
@ -550,7 +976,7 @@ pub async fn compile_latex(
|
||||||
|
|
||||||
if needs_retry {
|
if needs_retry {
|
||||||
let retry_result = tokio::task::spawn_blocking(move || {
|
let retry_result = tokio::task::spawn_blocking(move || {
|
||||||
compile_with_tectonic(&work_dir_clone, &main_file_clone)
|
compile_with_tectonic_subprocess(&work_dir_clone, &main_file_clone)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Retry task panicked: {}", e))?;
|
.map_err(|e| format!("Retry task panicked: {}", e))?;
|
||||||
|
|
@ -581,9 +1007,10 @@ pub async fn compile_latex(
|
||||||
.map_err(|e| format!("PDF read task panicked: {}", e))?
|
.map_err(|e| format!("PDF read task panicked: {}", e))?
|
||||||
.map_err(|e| format!("Failed to read PDF: {}", e))?;
|
.map_err(|e| format!("Failed to read PDF: {}", e))?;
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[latex] +{:.0}ms total (reuse={}) pdf_size={}KB",
|
"[latex] +{:.0}ms total (reuse={}, backend={}) pdf_size={}KB",
|
||||||
t0.elapsed().as_millis(),
|
t0.elapsed().as_millis(),
|
||||||
is_reuse,
|
is_reuse,
|
||||||
|
backend_label,
|
||||||
pdf_bytes.len() / 1024
|
pdf_bytes.len() / 1024
|
||||||
);
|
);
|
||||||
Ok(tauri::ipc::Response::new(pdf_bytes))
|
Ok(tauri::ipc::Response::new(pdf_bytes))
|
||||||
|
|
@ -598,7 +1025,7 @@ pub async fn compile_latex(
|
||||||
} else {
|
} else {
|
||||||
details
|
details
|
||||||
};
|
};
|
||||||
Err(format!("Compilation failed\n\n{}", msg))
|
Err(format!("Compilation failed ({})\n\n{}", backend_label, msg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -676,6 +1103,45 @@ pub async fn cleanup_all_builds(state: &LatexCompilerState) {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
// --- detect_bib_tool ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_bib_tool_biber() {
|
||||||
|
let content =
|
||||||
|
"\\documentclass{article}\n\\usepackage{biblatex}\n\\begin{document}\n\\end{document}";
|
||||||
|
assert_eq!(detect_bib_tool(content), BibTool::Biber);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_bib_tool_biblatex_with_options() {
|
||||||
|
let content = "\\documentclass{article}\n\\usepackage[style=apa,backend=biber]{biblatex}\n\\begin{document}";
|
||||||
|
assert_eq!(detect_bib_tool(content), BibTool::Biber);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_bib_tool_bibtex() {
|
||||||
|
let content = "\\documentclass{article}\n\\bibliography{refs}\n\\end{document}";
|
||||||
|
assert_eq!(detect_bib_tool(content), BibTool::BibTeX);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_bib_tool_addbibresource() {
|
||||||
|
let content = "\\documentclass{article}\n\\addbibresource{refs.bib}\n\\end{document}";
|
||||||
|
assert_eq!(detect_bib_tool(content), BibTool::BibTeX);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_bib_tool_none() {
|
||||||
|
let content = "\\documentclass{article}\n\\begin{document}\nHello\n\\end{document}";
|
||||||
|
assert_eq!(detect_bib_tool(content), BibTool::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_bib_tool_commented_out() {
|
||||||
|
let content = "\\documentclass{article}\n% \\bibliography{refs}\n% \\usepackage{biblatex}\n\\end{document}";
|
||||||
|
assert_eq!(detect_bib_tool(content), BibTool::None);
|
||||||
|
}
|
||||||
|
|
||||||
// --- extract_error_lines ---
|
// --- extract_error_lines ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -723,8 +1189,9 @@ mod tests {
|
||||||
log.push_str(&format!("! Error number {}\n", i));
|
log.push_str(&format!("! Error number {}\n", i));
|
||||||
}
|
}
|
||||||
let result = extract_error_lines(&log);
|
let result = extract_error_lines(&log);
|
||||||
|
assert!(result.contains("---- Engine output ----"));
|
||||||
let count = result.lines().count();
|
let count = result.lines().count();
|
||||||
assert!(count <= 10);
|
assert!(count <= 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- persistent_build_dir ---
|
// --- persistent_build_dir ---
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
|
#![recursion_limit = "512"]
|
||||||
|
|
||||||
|
mod anthropic_proxy;
|
||||||
mod claude;
|
mod claude;
|
||||||
|
mod claude_process;
|
||||||
mod history;
|
mod history;
|
||||||
mod latex;
|
mod latex;
|
||||||
mod skills;
|
mod skills;
|
||||||
|
|
@ -7,7 +11,15 @@ mod uv;
|
||||||
mod zotero;
|
mod zotero;
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
|
use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||||
|
use tauri_plugin_fs::FsExt;
|
||||||
|
|
||||||
|
/// Entry point for the `--tectonic-compile` subprocess mode.
|
||||||
|
/// Runs tectonic compilation in an isolated process so that C-level global state
|
||||||
|
/// (font cache, etc.) is cleaned up on exit, preventing assertion failures on retry.
|
||||||
|
pub fn tectonic_compile_subprocess(work_dir: &Path, main_file: &str) -> Result<(), String> {
|
||||||
|
latex::compile_with_tectonic(work_dir, main_file)
|
||||||
|
}
|
||||||
|
|
||||||
// --- External editor detection & opening ---
|
// --- External editor detection & opening ---
|
||||||
|
|
||||||
|
|
@ -175,6 +187,7 @@ fn create_new_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
.title("ClaudePrism")
|
.title("ClaudePrism")
|
||||||
.inner_size(1400.0, 900.0)
|
.inner_size(1400.0, 900.0)
|
||||||
.min_inner_size(800.0, 600.0)
|
.min_inner_size(800.0, 600.0)
|
||||||
|
.zoom_hotkeys_enabled(true)
|
||||||
.visible(false);
|
.visible(false);
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
|
|
@ -191,6 +204,241 @@ fn create_new_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn apply_windows_titlebar_theme(window: &tauri::WebviewWindow, dark: bool) -> Result<(), String> {
|
||||||
|
use std::ffi::c_void;
|
||||||
|
|
||||||
|
#[link(name = "dwmapi")]
|
||||||
|
extern "system" {
|
||||||
|
#[link_name = "DwmSetWindowAttribute"]
|
||||||
|
fn dwm_set_window_attribute(
|
||||||
|
hwnd: isize,
|
||||||
|
dwattribute: u32,
|
||||||
|
pvattribute: *const c_void,
|
||||||
|
cbattribute: u32,
|
||||||
|
) -> i32;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[link(name = "user32")]
|
||||||
|
extern "system" {
|
||||||
|
#[link_name = "SetWindowPos"]
|
||||||
|
fn set_window_pos(
|
||||||
|
hwnd: isize,
|
||||||
|
hwnd_insert_after: isize,
|
||||||
|
x: i32,
|
||||||
|
y: i32,
|
||||||
|
cx: i32,
|
||||||
|
cy: i32,
|
||||||
|
flags: u32,
|
||||||
|
) -> i32;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DWMWA_USE_IMMERSIVE_DARK_MODE: u32 = 20;
|
||||||
|
const SWP_NOSIZE: u32 = 0x0001;
|
||||||
|
const SWP_NOMOVE: u32 = 0x0002;
|
||||||
|
const SWP_NOZORDER: u32 = 0x0004;
|
||||||
|
const SWP_NOACTIVATE: u32 = 0x0010;
|
||||||
|
const SWP_FRAMECHANGED: u32 = 0x0020;
|
||||||
|
|
||||||
|
let hwnd = window
|
||||||
|
.hwnd()
|
||||||
|
.map_err(|e| format!("Failed to resolve native window handle: {}", e))?;
|
||||||
|
let hwnd = hwnd.0 as isize;
|
||||||
|
let dark_value: i32 = if dark { 1 } else { 0 };
|
||||||
|
let attr_size = std::mem::size_of_val(&dark_value) as u32;
|
||||||
|
|
||||||
|
let mut result = unsafe {
|
||||||
|
dwm_set_window_attribute(
|
||||||
|
hwnd,
|
||||||
|
DWMWA_USE_IMMERSIVE_DARK_MODE,
|
||||||
|
&dark_value as *const _ as *const _,
|
||||||
|
attr_size,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if result < 0 {
|
||||||
|
// Older Windows 10 builds used attribute 19 before Microsoft documented 20.
|
||||||
|
result = unsafe {
|
||||||
|
dwm_set_window_attribute(hwnd, 19, &dark_value as *const _ as *const _, attr_size)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows 11 honors explicit caption/text colors more reliably than the
|
||||||
|
// immersive flag alone, especially after runtime theme switches.
|
||||||
|
const DWMWA_CAPTION_COLOR: u32 = 35;
|
||||||
|
const DWMWA_TEXT_COLOR: u32 = 36;
|
||||||
|
let caption_color: u32 = if dark { 0x0010_1010 } else { 0x00F9_F9F9 };
|
||||||
|
let text_color: u32 = if dark { 0x00FF_FFFF } else { 0x0000_0000 };
|
||||||
|
unsafe {
|
||||||
|
let _ = dwm_set_window_attribute(
|
||||||
|
hwnd,
|
||||||
|
DWMWA_CAPTION_COLOR,
|
||||||
|
&caption_color as *const _ as *const _,
|
||||||
|
std::mem::size_of_val(&caption_color) as u32,
|
||||||
|
);
|
||||||
|
let _ = dwm_set_window_attribute(
|
||||||
|
hwnd,
|
||||||
|
DWMWA_TEXT_COLOR,
|
||||||
|
&text_color as *const _ as *const _,
|
||||||
|
std::mem::size_of_val(&text_color) as u32,
|
||||||
|
);
|
||||||
|
let _ = set_window_pos(
|
||||||
|
hwnd,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if result < 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"Failed to update Windows title bar theme: HRESULT 0x{:08X}",
|
||||||
|
result as u32
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn set_native_window_theme(window: tauri::WebviewWindow, theme: String) -> Result<(), String> {
|
||||||
|
let theme = theme.trim().to_ascii_lowercase();
|
||||||
|
let dark = theme == "dark";
|
||||||
|
let tauri_theme = if dark {
|
||||||
|
tauri::Theme::Dark
|
||||||
|
} else {
|
||||||
|
tauri::Theme::Light
|
||||||
|
};
|
||||||
|
|
||||||
|
window
|
||||||
|
.set_theme(Some(tauri_theme))
|
||||||
|
.map_err(|e| format!("Failed to set window theme: {}", e))?;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
apply_windows_titlebar_theme(&window, dark)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn allow_project_directory(app: tauri::AppHandle, root_path: String) -> Result<(), String> {
|
||||||
|
let fs_scope = app.fs_scope();
|
||||||
|
fs_scope
|
||||||
|
.allow_directory(&root_path, true)
|
||||||
|
.map_err(|e| format!("Failed to allow project directory: {}", e))?;
|
||||||
|
|
||||||
|
let asset_scope = app.state::<tauri::scope::Scopes>();
|
||||||
|
asset_scope
|
||||||
|
.allow_directory(&root_path, true)
|
||||||
|
.map_err(|e| format!("Failed to allow project assets: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct ProjectCandidate {
|
||||||
|
path: String,
|
||||||
|
name: String,
|
||||||
|
last_modified: u64,
|
||||||
|
has_main_tex: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn modified_ms(path: &Path) -> u64 {
|
||||||
|
std::fs::metadata(path)
|
||||||
|
.and_then(|metadata| metadata.modified())
|
||||||
|
.ok()
|
||||||
|
.and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.map(|duration| duration.as_millis() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_tex_file(dir: &Path) -> bool {
|
||||||
|
if dir.join("main.tex").is_file() || dir.join("document.tex").is_file() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::read_dir(dir)
|
||||||
|
.ok()
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|entries| entries.flatten())
|
||||||
|
.any(|entry| {
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_file() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
matches!(
|
||||||
|
path.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.map(|ext| ext.to_ascii_lowercase())
|
||||||
|
.as_deref(),
|
||||||
|
Some("tex" | "ltx")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_modified_ms(dir: &Path) -> u64 {
|
||||||
|
let mut latest = modified_ms(dir);
|
||||||
|
for relative in [
|
||||||
|
"main.tex",
|
||||||
|
"document.tex",
|
||||||
|
".prism/build/main.pdf",
|
||||||
|
".claudeprism/history.git/.git/refs/heads/master",
|
||||||
|
] {
|
||||||
|
latest = latest.max(modified_ms(&dir.join(relative)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_file() {
|
||||||
|
latest = latest.max(modified_ms(&path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
latest
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn list_default_projects() -> Result<Vec<ProjectCandidate>, String> {
|
||||||
|
let Some(home) = dirs::home_dir() else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
let base = home.join("Documents").join("ClaudePrism");
|
||||||
|
if !base.is_dir() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut projects = Vec::new();
|
||||||
|
let entries = std::fs::read_dir(&base)
|
||||||
|
.map_err(|e| format!("Failed to read default project directory: {}", e))?;
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if name.starts_with('.') || !has_tex_file(&path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
projects.push(ProjectCandidate {
|
||||||
|
path: path.to_string_lossy().to_string(),
|
||||||
|
name,
|
||||||
|
last_modified: project_modified_ms(&path),
|
||||||
|
has_main_tex: path.join("main.tex").is_file() || path.join("document.tex").is_file(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
projects.sort_by(|a, b| b.last_modified.cmp(&a.last_modified));
|
||||||
|
Ok(projects)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Debug logging from JS (survives white-screen crashes) ---
|
// --- Debug logging from JS (survives white-screen crashes) ---
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -198,6 +446,67 @@ fn js_log(msg: String) {
|
||||||
eprintln!("[js] {}", msg);
|
eprintln!("[js] {}", msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Debug window ---
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn open_debug_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
// If a debug window already exists, just focus it
|
||||||
|
if let Some(win) = app.get_webview_window("debug") {
|
||||||
|
win.set_focus().map_err(|e| e.to_string())?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = WebviewUrl::App("index.html?debug=1".into());
|
||||||
|
WebviewWindowBuilder::new(&app, "debug", url)
|
||||||
|
.title("ClaudePrism — Debug")
|
||||||
|
.inner_size(560.0, 700.0)
|
||||||
|
.min_inner_size(400.0, 400.0)
|
||||||
|
.zoom_hotkeys_enabled(true)
|
||||||
|
.visible(true)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to create debug window: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- System info for debug panel & bug reports ---
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct SystemInfo {
|
||||||
|
os: String,
|
||||||
|
os_version: String,
|
||||||
|
arch: String,
|
||||||
|
app_version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_system_info(app: tauri::AppHandle) -> SystemInfo {
|
||||||
|
// Get OS version from uname on unix, or fallback to "unknown"
|
||||||
|
let os_version = {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
std::process::Command::new("uname")
|
||||||
|
.arg("-r")
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string())
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
"unknown".to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
SystemInfo {
|
||||||
|
os: std::env::consts::OS.to_string(),
|
||||||
|
os_version,
|
||||||
|
arch: std::env::consts::ARCH.to_string(),
|
||||||
|
app_version: app.package_info().version.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Clipboard file paths (for Cmd+V paste in file tree) ---
|
// --- Clipboard file paths (for Cmd+V paste in file tree) ---
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -259,27 +568,61 @@ pub fn run() {
|
||||||
.manage(claude::ClaudeProcessState::default())
|
.manage(claude::ClaudeProcessState::default())
|
||||||
.manage(latex::LatexCompilerState::default())
|
.manage(latex::LatexCompilerState::default())
|
||||||
.manage(zotero::ZoteroOAuthState::default())
|
.manage(zotero::ZoteroOAuthState::default())
|
||||||
.setup(|_app| Ok(()))
|
.setup(|app| {
|
||||||
|
// Safety net: force-show the main window after a timeout if the
|
||||||
|
// frontend JS never calls `getCurrentWindow().show()`.
|
||||||
|
// This prevents the window from staying permanently hidden when
|
||||||
|
// WKWebView fails to execute JS (e.g. WebKit top-level-await bug
|
||||||
|
// on macOS 12). See https://bugs.webkit.org/show_bug.cgi?id=242740
|
||||||
|
let handle = app.handle().clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(8)).await;
|
||||||
|
if let Some(window) = handle.get_webview_window("main") {
|
||||||
|
if !window.is_visible().unwrap_or(true) {
|
||||||
|
eprintln!("[safety] Main window still hidden after 8s, force-showing");
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
create_new_window,
|
create_new_window,
|
||||||
|
set_native_window_theme,
|
||||||
|
allow_project_directory,
|
||||||
|
list_default_projects,
|
||||||
detect_editors,
|
detect_editors,
|
||||||
open_in_editor,
|
open_in_editor,
|
||||||
js_log,
|
js_log,
|
||||||
read_clipboard_file_paths,
|
read_clipboard_file_paths,
|
||||||
latex::compile_latex,
|
latex::compile_latex,
|
||||||
latex::synctex_edit,
|
latex::synctex_edit,
|
||||||
|
latex::detect_texlive,
|
||||||
claude::check_claude_status,
|
claude::check_claude_status,
|
||||||
claude::install_claude_cli,
|
claude::install_claude_cli,
|
||||||
claude::login_claude,
|
claude::login_claude,
|
||||||
|
claude::save_anthropic_api_key,
|
||||||
|
claude::verify_openai_compatible_api_key,
|
||||||
|
claude::list_openai_compatible_models,
|
||||||
|
claude::list_openai_compatible_credential_models,
|
||||||
|
claude::clear_anthropic_api_key,
|
||||||
|
claude::list_openai_compatible_credentials,
|
||||||
|
claude::delete_openai_compatible_credential,
|
||||||
|
claude::set_active_openai_compatible_credential,
|
||||||
claude::execute_claude_code,
|
claude::execute_claude_code,
|
||||||
claude::continue_claude_code,
|
claude::continue_claude_code,
|
||||||
claude::resume_claude_code,
|
claude::resume_claude_code,
|
||||||
claude::cancel_claude_execution,
|
claude::cancel_claude_execution,
|
||||||
|
claude::interrupt_claude_execution,
|
||||||
claude::run_shell_command,
|
claude::run_shell_command,
|
||||||
|
claude::migrate_project_sessions,
|
||||||
claude::get_claude_fast_mode,
|
claude::get_claude_fast_mode,
|
||||||
claude::set_claude_fast_mode,
|
claude::set_claude_fast_mode,
|
||||||
claude::list_claude_sessions,
|
claude::list_claude_sessions,
|
||||||
|
claude::generate_claude_session_title,
|
||||||
claude::load_session_history,
|
claude::load_session_history,
|
||||||
|
claude::delete_claude_session,
|
||||||
zotero::zotero_start_oauth,
|
zotero::zotero_start_oauth,
|
||||||
zotero::zotero_complete_oauth,
|
zotero::zotero_complete_oauth,
|
||||||
zotero::zotero_cancel_oauth,
|
zotero::zotero_cancel_oauth,
|
||||||
|
|
@ -297,8 +640,10 @@ pub fn run() {
|
||||||
slash_commands::slash_command_delete,
|
slash_commands::slash_command_delete,
|
||||||
skills::install_scientific_skills,
|
skills::install_scientific_skills,
|
||||||
skills::install_scientific_skills_global,
|
skills::install_scientific_skills_global,
|
||||||
|
skills::import_skill_from_folder,
|
||||||
skills::check_skills_installed,
|
skills::check_skills_installed,
|
||||||
skills::list_installed_skills,
|
skills::list_installed_skills,
|
||||||
|
skills::delete_installed_skill,
|
||||||
skills::uninstall_scientific_skills,
|
skills::uninstall_scientific_skills,
|
||||||
skills::get_skill_categories,
|
skills::get_skill_categories,
|
||||||
skills::get_skill_content,
|
skills::get_skill_content,
|
||||||
|
|
@ -307,6 +652,8 @@ pub fn run() {
|
||||||
uv::setup_project_venv,
|
uv::setup_project_venv,
|
||||||
uv::uv_add_packages,
|
uv::uv_add_packages,
|
||||||
uv::uv_run_command,
|
uv::uv_run_command,
|
||||||
|
get_system_info,
|
||||||
|
open_debug_window,
|
||||||
])
|
])
|
||||||
.build(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while building tauri application");
|
.expect("error while building tauri application");
|
||||||
|
|
@ -320,6 +667,42 @@ pub fn run() {
|
||||||
tauri::RunEvent::Ready => {
|
tauri::RunEvent::Ready => {
|
||||||
set_macos_app_icon();
|
set_macos_app_icon();
|
||||||
}
|
}
|
||||||
|
// Workaround: WKWebView sometimes fails to repaint after the app
|
||||||
|
// returns from background, leaving a black screen. We apply two
|
||||||
|
// complementary fixes on focus-restore:
|
||||||
|
// 1. Nudge the window size by 1 px and back (forces native
|
||||||
|
// compositing layer to re-composite).
|
||||||
|
// 2. Trigger a DOM reflow via JS (forces WKWebView render tree
|
||||||
|
// rebuild without losing app state).
|
||||||
|
// Either one alone may not cover all cases.
|
||||||
|
// See https://github.com/tauri-apps/tauri/issues/5226
|
||||||
|
// https://github.com/tauri-apps/tauri/issues/14843
|
||||||
|
tauri::RunEvent::WindowEvent {
|
||||||
|
ref label,
|
||||||
|
event: tauri::WindowEvent::Focused(true),
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if let Some(window) = app_handle.get_webview_window(label) {
|
||||||
|
// macOS: nudge window size to fix black screen after wake/focus
|
||||||
|
// See https://github.com/tauri-apps/tauri/issues/5226
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
if let Ok(size) = window.inner_size() {
|
||||||
|
let _ = window.set_size(tauri::Size::Physical(tauri::PhysicalSize {
|
||||||
|
width: size.width + 1,
|
||||||
|
height: size.height,
|
||||||
|
}));
|
||||||
|
let _ = window.set_size(tauri::Size::Physical(size));
|
||||||
|
}
|
||||||
|
let _ = window.eval(
|
||||||
|
"document.body.style.display='none';\
|
||||||
|
document.body.offsetHeight;\
|
||||||
|
document.body.style.display='';",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let _ = window.emit("window-focus-restored", ());
|
||||||
|
}
|
||||||
|
}
|
||||||
tauri::RunEvent::WindowEvent {
|
tauri::RunEvent::WindowEvent {
|
||||||
label,
|
label,
|
||||||
event: tauri::WindowEvent::Destroyed,
|
event: tauri::WindowEvent::Destroyed,
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,21 @@
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
// Hidden CLI mode: when invoked with `--tectonic-compile <work_dir> <main_file>`,
|
||||||
|
// run tectonic in this subprocess and exit. This isolates tectonic's global C state
|
||||||
|
// so that a failed compilation doesn't poison the font cache for subsequent runs.
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
if args.len() >= 4 && args[1] == "--tectonic-compile" {
|
||||||
|
let work_dir = std::path::Path::new(&args[2]);
|
||||||
|
let main_file = &args[3];
|
||||||
|
match claude_prism_desktop_lib::tectonic_compile_subprocess(work_dir, main_file) {
|
||||||
|
Ok(()) => std::process::exit(0),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("{}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
claude_prism_desktop_lib::run()
|
claude_prism_desktop_lib::run()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -200,23 +200,16 @@ fn load_skills_from_dir(dir: &Path, scope: &str) -> Vec<SlashCommand> {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
let entries = match fs::read_dir(dir) {
|
let mut skill_dirs = Vec::new();
|
||||||
Ok(e) => e,
|
collect_skill_dirs(dir, &mut skill_dirs);
|
||||||
Err(_) => return Vec::new(),
|
skill_dirs.sort();
|
||||||
};
|
|
||||||
|
|
||||||
let mut skills = Vec::new();
|
let mut skills = Vec::new();
|
||||||
|
let mut seen_ids = std::collections::HashSet::new();
|
||||||
|
|
||||||
for entry in entries.flatten() {
|
for path in skill_dirs {
|
||||||
let path = entry.path();
|
let Some(skill_md) = find_skill_md(&path) else {
|
||||||
if !path.is_dir() {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
|
|
||||||
let skill_md = path.join("SKILL.md");
|
|
||||||
if !skill_md.exists() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = match fs::read_to_string(&skill_md) {
|
let content = match fs::read_to_string(&skill_md) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
|
|
@ -248,6 +241,9 @@ fn load_skills_from_dir(dir: &Path, scope: &str) -> Vec<SlashCommand> {
|
||||||
.map(|d| d.chars().take(200).collect());
|
.map(|d| d.chars().take(200).collect());
|
||||||
|
|
||||||
let id = format!("skill-{}", folder_name);
|
let id = format!("skill-{}", folder_name);
|
||||||
|
if !seen_ids.insert(id.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
skills.push(SlashCommand {
|
skills.push(SlashCommand {
|
||||||
id,
|
id,
|
||||||
|
|
@ -269,6 +265,51 @@ fn load_skills_from_dir(dir: &Path, scope: &str) -> Vec<SlashCommand> {
|
||||||
skills
|
skills
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn find_skill_md(skill_dir: &Path) -> Option<PathBuf> {
|
||||||
|
for name in ["SKILL.md", "skill.md"] {
|
||||||
|
let candidate = skill_dir.join(name);
|
||||||
|
if candidate.is_file() {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = fs::read_dir(skill_dir).ok()?;
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_file()
|
||||||
|
&& path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md"))
|
||||||
|
{
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_skill_dirs(root: &Path, output: &mut Vec<PathBuf>) {
|
||||||
|
if find_skill_md(root).is_some() {
|
||||||
|
output.push(root.to_path_buf());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(entries) = fs::read_dir(root) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let Ok(file_type) = entry.file_type() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if file_type.is_symlink() || !file_type.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
collect_skill_dirs(&entry.path(), output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn create_default_commands() -> Vec<SlashCommand> {
|
fn create_default_commands() -> Vec<SlashCommand> {
|
||||||
vec![
|
vec![
|
||||||
SlashCommand {
|
SlashCommand {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use tauri::{Emitter, WebviewWindow};
|
use tauri::{Emitter, WebviewWindow};
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
|
||||||
|
|
@ -113,6 +113,28 @@ fn venv_python(venv_dir: &std::path::Path) -> PathBuf {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn venv_pip(venv_dir: &std::path::Path) -> PathBuf {
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
venv_bin_dir(venv_dir).join("pip")
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
venv_bin_dir(venv_dir).join("pip.exe")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn venv_pip_shim(venv_dir: &std::path::Path) -> PathBuf {
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
venv_bin_dir(venv_dir).join("pip")
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
venv_bin_dir(venv_dir).join("pip.cmd")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn path_with_venv(venv_dir: &std::path::Path) -> String {
|
fn path_with_venv(venv_dir: &std::path::Path) -> String {
|
||||||
let bin = venv_bin_dir(venv_dir);
|
let bin = venv_bin_dir(venv_dir);
|
||||||
let current = std::env::var("PATH").unwrap_or_default();
|
let current = std::env::var("PATH").unwrap_or_default();
|
||||||
|
|
@ -123,6 +145,73 @@ fn path_with_venv(venv_dir: &std::path::Path) -> String {
|
||||||
format!("{}{}{}", bin.to_string_lossy(), sep, current)
|
format!("{}{}{}", bin.to_string_lossy(), sep, current)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_pip_shim(venv_dir: &Path) -> Result<(), String> {
|
||||||
|
let uv_bin = find_uv_binary().unwrap_or_else(|_| "uv".to_string());
|
||||||
|
let shim_path = venv_pip_shim(venv_dir);
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let content = format!(
|
||||||
|
"@echo off\r\nset \"VIRTUAL_ENV={}\"\r\n\"{}\" pip %*\r\n",
|
||||||
|
venv_dir.to_string_lossy(),
|
||||||
|
uv_bin
|
||||||
|
);
|
||||||
|
std::fs::write(&shim_path, &content)
|
||||||
|
.map_err(|e| format!("Failed to create pip shim: {}", e))?;
|
||||||
|
let pip3_path = venv_bin_dir(venv_dir).join("pip3.cmd");
|
||||||
|
let _ = std::fs::write(pip3_path, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
let content = format!(
|
||||||
|
"#!/bin/sh\nVIRTUAL_ENV=\"{}\" exec \"{}\" pip \"$@\"\n",
|
||||||
|
venv_dir.to_string_lossy(),
|
||||||
|
uv_bin
|
||||||
|
);
|
||||||
|
std::fs::write(&shim_path, content)
|
||||||
|
.map_err(|e| format!("Failed to create pip shim: {}", e))?;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mut perms = std::fs::metadata(&shim_path)
|
||||||
|
.map_err(|e| format!("Failed to stat pip shim: {}", e))?
|
||||||
|
.permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
std::fs::set_permissions(&shim_path, perms)
|
||||||
|
.map_err(|e| format!("Failed to mark pip shim executable: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_venv_pip(venv_dir: &Path) -> Result<(), String> {
|
||||||
|
if venv_pip(venv_dir).exists() || venv_pip_shim(venv_dir).exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let python = venv_python(venv_dir);
|
||||||
|
if !python.exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"Project .venv is missing Python at {}",
|
||||||
|
python.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ensure_cmd = tokio::process::Command::new(&python);
|
||||||
|
ensure_cmd.args(["-m", "ensurepip", "--upgrade"]);
|
||||||
|
ensure_cmd.env("VIRTUAL_ENV", venv_dir);
|
||||||
|
ensure_cmd.env("PATH", path_with_venv(venv_dir));
|
||||||
|
ensure_cmd.env("PYTHONNOUSERSITE", "1");
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
ensure_cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
match ensure_cmd.output().await {
|
||||||
|
Ok(output) if output.status.success() && venv_pip(venv_dir).exists() => Ok(()),
|
||||||
|
_ => write_pip_shim(venv_dir),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Tauri Commands ───
|
// ─── Tauri Commands ───
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -143,7 +232,6 @@ pub async fn check_uv_status() -> Result<UvStatus, String> {
|
||||||
version_cmd.arg("--version");
|
version_cmd.arg("--version");
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
|
|
||||||
version_cmd.creation_flags(CREATE_NO_WINDOW);
|
version_cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
}
|
}
|
||||||
let version_output = version_cmd.output();
|
let version_output = version_cmd.output();
|
||||||
|
|
@ -214,7 +302,6 @@ pub async fn install_uv(window: WebviewWindow) -> Result<(), String> {
|
||||||
};
|
};
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
let mut cmd = {
|
let mut cmd = {
|
||||||
|
|
||||||
let mut c = tokio::process::Command::new("powershell");
|
let mut c = tokio::process::Command::new("powershell");
|
||||||
c.creation_flags(CREATE_NO_WINDOW);
|
c.creation_flags(CREATE_NO_WINDOW);
|
||||||
c.args([
|
c.args([
|
||||||
|
|
@ -230,38 +317,13 @@ pub async fn install_uv(window: WebviewWindow) -> Result<(), String> {
|
||||||
cmd.stdout(std::process::Stdio::piped());
|
cmd.stdout(std::process::Stdio::piped());
|
||||||
cmd.stderr(std::process::Stdio::piped());
|
cmd.stderr(std::process::Stdio::piped());
|
||||||
|
|
||||||
// Inherit essential environment variables
|
// Inherit essential environment variables (shared helper handles case-insensitive matching)
|
||||||
for (key, value) in std::env::vars() {
|
for (key, value) in std::env::vars() {
|
||||||
if key == "PATH"
|
if key.eq_ignore_ascii_case("PATH") || crate::claude::is_essential_env_var(&key) {
|
||||||
|| key == "HOME"
|
|
||||||
|| key == "USER"
|
|
||||||
|| key == "SHELL"
|
|
||||||
|| key == "LANG"
|
|
||||||
|| key.starts_with("LC_")
|
|
||||||
|| key == "HOMEBREW_PREFIX"
|
|
||||||
|| key == "HOMEBREW_CELLAR"
|
|
||||||
|| key == "HTTP_PROXY"
|
|
||||||
|| key == "HTTPS_PROXY"
|
|
||||||
|| key == "NO_PROXY"
|
|
||||||
|| key == "ALL_PROXY"
|
|
||||||
// Windows-essential variables
|
|
||||||
|| key == "USERPROFILE"
|
|
||||||
|| key == "LOCALAPPDATA"
|
|
||||||
|| key == "APPDATA"
|
|
||||||
|| key == "TEMP"
|
|
||||||
|| key == "TMP"
|
|
||||||
|| key == "SystemRoot"
|
|
||||||
|| key == "WINDIR"
|
|
||||||
|| key == "PROGRAMFILES"
|
|
||||||
|| key == "PROGRAMFILES(X86)"
|
|
||||||
|| key == "COMMONPROGRAMFILES"
|
|
||||||
|| key == "SystemDrive"
|
|
||||||
|| key == "COMPUTERNAME"
|
|
||||||
|| key == "USERNAME"
|
|
||||||
{
|
|
||||||
cmd.env(&key, &value);
|
cmd.env(&key, &value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
crate::claude::apply_proxy_env_to_command(&mut cmd, Some(&window));
|
||||||
|
|
||||||
let mut child = cmd
|
let mut child = cmd
|
||||||
.spawn()
|
.spawn()
|
||||||
|
|
@ -315,6 +377,7 @@ pub async fn setup_project_venv(project_path: String) -> Result<VenvInfo, String
|
||||||
|
|
||||||
// If venv already exists, just return info
|
// If venv already exists, just return info
|
||||||
if venv_dir.exists() {
|
if venv_dir.exists() {
|
||||||
|
ensure_venv_pip(&venv_dir).await?;
|
||||||
let python = venv_python(&venv_dir);
|
let python = venv_python(&venv_dir);
|
||||||
return Ok(VenvInfo {
|
return Ok(VenvInfo {
|
||||||
venv_path: venv_dir.to_string_lossy().to_string(),
|
venv_path: venv_dir.to_string_lossy().to_string(),
|
||||||
|
|
@ -327,11 +390,11 @@ pub async fn setup_project_venv(project_path: String) -> Result<VenvInfo, String
|
||||||
|
|
||||||
// Create venv: uv venv <project_path>/.venv
|
// Create venv: uv venv <project_path>/.venv
|
||||||
let mut venv_cmd = tokio::process::Command::new(&uv_bin);
|
let mut venv_cmd = tokio::process::Command::new(&uv_bin);
|
||||||
venv_cmd.args(["venv", &venv_dir.to_string_lossy()]);
|
let venv_arg = venv_dir.to_string_lossy().to_string();
|
||||||
|
venv_cmd.args(["venv", "--seed", venv_arg.as_str()]);
|
||||||
venv_cmd.current_dir(project);
|
venv_cmd.current_dir(project);
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
|
|
||||||
venv_cmd.creation_flags(CREATE_NO_WINDOW);
|
venv_cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
}
|
}
|
||||||
let output = venv_cmd
|
let output = venv_cmd
|
||||||
|
|
@ -345,6 +408,7 @@ pub async fn setup_project_venv(project_path: String) -> Result<VenvInfo, String
|
||||||
}
|
}
|
||||||
|
|
||||||
let python = venv_python(&venv_dir);
|
let python = venv_python(&venv_dir);
|
||||||
|
ensure_venv_pip(&venv_dir).await?;
|
||||||
|
|
||||||
Ok(VenvInfo {
|
Ok(VenvInfo {
|
||||||
venv_path: venv_dir.to_string_lossy().to_string(),
|
venv_path: venv_dir.to_string_lossy().to_string(),
|
||||||
|
|
@ -372,10 +436,11 @@ pub async fn uv_add_packages(
|
||||||
pip_cmd.args(&args);
|
pip_cmd.args(&args);
|
||||||
pip_cmd.current_dir(&project_path);
|
pip_cmd.current_dir(&project_path);
|
||||||
pip_cmd.env("VIRTUAL_ENV", &venv_dir);
|
pip_cmd.env("VIRTUAL_ENV", &venv_dir);
|
||||||
|
pip_cmd.env("UV_PROJECT_ENVIRONMENT", &venv_dir);
|
||||||
|
pip_cmd.env("PYTHONNOUSERSITE", "1");
|
||||||
pip_cmd.env("PATH", path_with_venv(&venv_dir));
|
pip_cmd.env("PATH", path_with_venv(&venv_dir));
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
|
|
||||||
pip_cmd.creation_flags(CREATE_NO_WINDOW);
|
pip_cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
}
|
}
|
||||||
let output = pip_cmd
|
let output = pip_cmd
|
||||||
|
|
@ -416,10 +481,12 @@ pub async fn uv_run_command(
|
||||||
run_cmd.args(args);
|
run_cmd.args(args);
|
||||||
run_cmd.current_dir(&project_path);
|
run_cmd.current_dir(&project_path);
|
||||||
run_cmd.env("VIRTUAL_ENV", &venv_dir);
|
run_cmd.env("VIRTUAL_ENV", &venv_dir);
|
||||||
|
run_cmd.env("UV_PROJECT_ENVIRONMENT", &venv_dir);
|
||||||
|
run_cmd.env("PYTHONNOUSERSITE", "1");
|
||||||
|
run_cmd.env("PIP_REQUIRE_VIRTUALENV", "true");
|
||||||
run_cmd.env("PATH", path_with_venv(&venv_dir));
|
run_cmd.env("PATH", path_with_venv(&venv_dir));
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
|
|
||||||
run_cmd.creation_flags(CREATE_NO_WINDOW);
|
run_cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
}
|
}
|
||||||
let output = run_cmd
|
let output = run_cmd
|
||||||
|
|
|
||||||
50
apps/desktop/src-tauri/symbols.map
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
/* Version script to hide statically linked library symbols.
|
||||||
|
*
|
||||||
|
* Prevents symbol collisions between our statically linked
|
||||||
|
* ICU/HarfBuzz/FreeType/Fontconfig (from vcpkg, used by Tectonic)
|
||||||
|
* and the dynamically linked versions used by WebKit2GTK at runtime.
|
||||||
|
*
|
||||||
|
* Without this, the ELF dynamic linker resolves WebKit2GTK's ICU
|
||||||
|
* calls to our static symbols (different version), causing a segfault
|
||||||
|
* at startup. Fixes #100; preserves #91 fix (static ICU = no
|
||||||
|
* libicuuc.so.70 not-found error on newer distros).
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
local:
|
||||||
|
/* ICU4C */
|
||||||
|
u_*;
|
||||||
|
ucnv_*;
|
||||||
|
ucol_*;
|
||||||
|
ures_*;
|
||||||
|
udata_*;
|
||||||
|
ucal_*;
|
||||||
|
udat_*;
|
||||||
|
unum_*;
|
||||||
|
uset_*;
|
||||||
|
ustr_*;
|
||||||
|
ubidi_*;
|
||||||
|
ubrk_*;
|
||||||
|
uidna_*;
|
||||||
|
uloc_*;
|
||||||
|
unorm_*;
|
||||||
|
unorm2_*;
|
||||||
|
utrans_*;
|
||||||
|
icu_*;
|
||||||
|
UCNV_*;
|
||||||
|
|
||||||
|
/* HarfBuzz */
|
||||||
|
hb_*;
|
||||||
|
|
||||||
|
/* FreeType */
|
||||||
|
FT_*;
|
||||||
|
ft_*;
|
||||||
|
|
||||||
|
/* Fontconfig */
|
||||||
|
Fc*;
|
||||||
|
|
||||||
|
/* Graphite2 */
|
||||||
|
gr_*;
|
||||||
|
|
||||||
|
/* libpng (often pulled in by FreeType) */
|
||||||
|
png_*;
|
||||||
|
};
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
"$schema": "https://raw.githubusercontent.com/nicegui-unofficial/nicegui-tauri-template/main/src-tauri/tauri.conf-v2-schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicegui-unofficial/nicegui-tauri-template/main/src-tauri/tauri.conf-v2-schema.json",
|
||||||
"identifier": "com.claude-prism.desktop",
|
"identifier": "com.claude-prism.desktop",
|
||||||
"productName": "ClaudePrism",
|
"productName": "ClaudePrism",
|
||||||
"version": "1.0.4",
|
"version": "1.3.0",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "corepack pnpm dev",
|
||||||
"beforeBuildCommand": "pnpm build",
|
"beforeBuildCommand": "corepack pnpm build",
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:1420",
|
||||||
"frontendDist": "../dist"
|
"frontendDist": "../dist"
|
||||||
},
|
},
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"visible": false,
|
"visible": false,
|
||||||
"titleBarStyle": "Overlay",
|
"titleBarStyle": "Overlay",
|
||||||
"hiddenTitle": true,
|
"hiddenTitle": true,
|
||||||
|
"zoomHotkeysEnabled": true,
|
||||||
"trafficLightPosition": {
|
"trafficLightPosition": {
|
||||||
"x": 12,
|
"x": 12,
|
||||||
"y": 12
|
"y": 12
|
||||||
|
|
|
||||||
5
apps/desktop/src-tauri/tauri.local-build.conf.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"bundle": {
|
||||||
|
"createUpdaterArtifacts": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider, useTheme } from "next-themes";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
|
@ -7,20 +7,79 @@ import { useDocumentStore } from "@/stores/document-store";
|
||||||
import { useClaudeChatStore } from "@/stores/claude-chat-store";
|
import { useClaudeChatStore } from "@/stores/claude-chat-store";
|
||||||
import { ProjectPicker } from "@/components/project-picker";
|
import { ProjectPicker } from "@/components/project-picker";
|
||||||
import { WorkspaceLayout } from "@/components/workspace/workspace-layout";
|
import { WorkspaceLayout } from "@/components/workspace/workspace-layout";
|
||||||
import { useEffect, useState } from "react";
|
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import {
|
|
||||||
ScientificSkillsOnboarding,
|
|
||||||
shouldShowOnboarding,
|
|
||||||
} from "@/components/scientific-skills/scientific-skills-onboarding";
|
|
||||||
import { useUvSetupStore } from "@/stores/uv-setup-store";
|
import { useUvSetupStore } from "@/stores/uv-setup-store";
|
||||||
import { ErrorFallback } from "@/components/error-fallback";
|
import { ErrorFallback } from "@/components/error-fallback";
|
||||||
|
import { createLogger } from "@/lib/debug/logger";
|
||||||
|
import { EnvironmentOnboarding } from "@/components/environment-onboarding";
|
||||||
|
|
||||||
|
const log = createLogger("app");
|
||||||
|
|
||||||
|
const LazyDebugPage = lazy(() =>
|
||||||
|
import("@/components/debug/debug-page").then((m) => ({
|
||||||
|
default: m.DebugPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ClaudeSessionInfo {
|
||||||
|
session_id: string;
|
||||||
|
title: string;
|
||||||
|
last_modified: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NativeWindowThemeBridge() {
|
||||||
|
const { resolvedTheme, theme } = useTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const syncNativeTheme = () => {
|
||||||
|
const isDark =
|
||||||
|
document.documentElement.classList.contains("dark") ||
|
||||||
|
resolvedTheme === "dark";
|
||||||
|
const nativeTheme = isDark ? "dark" : "light";
|
||||||
|
|
||||||
|
document.documentElement.style.colorScheme = nativeTheme;
|
||||||
|
invoke("set_native_window_theme", { theme: nativeTheme })
|
||||||
|
.catch((err) => {
|
||||||
|
log.warn("Failed to sync native window theme via Rust command", {
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
return getCurrentWindow().setTheme(nativeTheme);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log.warn("Failed to sync native window theme via JS API", {
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
syncNativeTheme();
|
||||||
|
|
||||||
|
const observer = new MutationObserver(syncNativeTheme);
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["class"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const systemThemeQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
systemThemeQuery.addEventListener("change", syncNativeTheme);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
systemThemeQuery.removeEventListener("change", syncNativeTheme);
|
||||||
|
};
|
||||||
|
}, [resolvedTheme, theme]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function WorkspaceWithClaude() {
|
function WorkspaceWithClaude() {
|
||||||
const projectRoot = useDocumentStore((s) => s.projectRoot);
|
const projectRoot = useDocumentStore((s) => s.projectRoot);
|
||||||
const initialized = useDocumentStore((s) => s.initialized);
|
const initialized = useDocumentStore((s) => s.initialized);
|
||||||
const [showSkillsOnboarding, setShowSkillsOnboarding] = useState(false);
|
const autoResumedProjectRef = useRef<string | null>(null);
|
||||||
|
const chatProjectRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Update window title
|
// Update window title
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -30,15 +89,11 @@ function WorkspaceWithClaude() {
|
||||||
}
|
}
|
||||||
}, [projectRoot]);
|
}, [projectRoot]);
|
||||||
|
|
||||||
// Show scientific skills onboarding on first launch
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialized) return;
|
if (chatProjectRef.current === projectRoot) return;
|
||||||
if (shouldShowOnboarding()) {
|
chatProjectRef.current = projectRoot;
|
||||||
// Small delay so the workspace renders first
|
useClaudeChatStore.getState().resetForProject(projectRoot ?? null);
|
||||||
const timer = setTimeout(() => setShowSkillsOnboarding(true), 800);
|
}, [projectRoot]);
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [initialized]);
|
|
||||||
|
|
||||||
// Auto-setup Python venv when project opens
|
// Auto-setup Python venv when project opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -53,16 +108,71 @@ function WorkspaceWithClaude() {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error("Failed to setup Python venv:", err);
|
log.error("Failed to setup Python venv", { error: String(err) });
|
||||||
});
|
});
|
||||||
}, [initialized, projectRoot]);
|
}, [initialized, projectRoot]);
|
||||||
|
|
||||||
|
// Open the most recent chat when entering a project.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectRoot) {
|
||||||
|
autoResumedProjectRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!initialized) return;
|
||||||
|
if (autoResumedProjectRef.current === projectRoot) return;
|
||||||
|
|
||||||
|
const chatState = useClaudeChatStore.getState();
|
||||||
|
if (chatState.pendingInitialPrompt) return;
|
||||||
|
|
||||||
|
autoResumedProjectRef.current = projectRoot;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
invoke<ClaudeSessionInfo[]>("list_claude_sessions", {
|
||||||
|
projectPath: projectRoot,
|
||||||
|
generateTitles: false,
|
||||||
|
})
|
||||||
|
.then((sessions) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const latest = sessions
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.last_modified - a.last_modified)[0];
|
||||||
|
|
||||||
|
const current = useClaudeChatStore.getState();
|
||||||
|
if (current.pendingInitialPrompt || current.isStreaming) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!latest?.session_id) {
|
||||||
|
current.newSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
current.resumeSession(latest.session_id, latest.title).catch((err) => {
|
||||||
|
log.warn("Failed to auto-resume latest chat session", {
|
||||||
|
sessionId: latest.session_id,
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log.warn("Failed to auto-resume latest chat session", {
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [initialized, projectRoot]);
|
||||||
|
|
||||||
// Consume pending initial prompt from project wizard
|
// Consume pending initial prompt from project wizard
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialized) return;
|
if (!initialized) return;
|
||||||
// Delay to let ClaudeChatDrawer mount and register event listeners
|
// Delay to let ClaudeChatDrawer mount and register event listeners
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const prompt = useClaudeChatStore.getState().consumePendingInitialPrompt();
|
const prompt = useClaudeChatStore
|
||||||
|
.getState()
|
||||||
|
.consumePendingInitialPrompt();
|
||||||
if (prompt) {
|
if (prompt) {
|
||||||
useClaudeChatStore.getState().sendPrompt(prompt);
|
useClaudeChatStore.getState().sendPrompt(prompt);
|
||||||
}
|
}
|
||||||
|
|
@ -70,39 +180,87 @@ function WorkspaceWithClaude() {
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [initialized]);
|
}, [initialized]);
|
||||||
|
|
||||||
return (
|
return <WorkspaceLayout />;
|
||||||
<>
|
|
||||||
<WorkspaceLayout />
|
|
||||||
{showSkillsOnboarding && (
|
|
||||||
<ScientificSkillsOnboarding
|
|
||||||
onClose={() => setShowSkillsOnboarding(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function App({ onReady }: { onReady?: () => void }) {
|
export function App({ onReady }: { onReady?: () => void }) {
|
||||||
const projectRoot = useDocumentStore((s) => s.projectRoot);
|
const projectRoot = useDocumentStore((s) => s.projectRoot);
|
||||||
|
const [showDebug, setShowDebug] = useState(false);
|
||||||
|
|
||||||
// Register global keyboard shortcuts (Cmd+S, Cmd+N) at the app level
|
// Register global keyboard shortcuts (Cmd+S, Cmd+N) at the app level
|
||||||
useKeyboardShortcuts();
|
useKeyboardShortcuts();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const preventNativeContextMenu = (event: MouseEvent) => {
|
||||||
|
if (event.defaultPrevented) return;
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("contextmenu", preventNativeContextMenu);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("contextmenu", preventNativeContextMenu);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onReady?.();
|
onReady?.();
|
||||||
}, [onReady]);
|
}, [onReady]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectRoot) {
|
||||||
|
getCurrentWindow().setTitle("ClaudePrism");
|
||||||
|
}
|
||||||
|
}, [projectRoot]);
|
||||||
|
|
||||||
|
// Listen for debug panel toggle (Ctrl+Shift+D)
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setShowDebug((prev) => !prev);
|
||||||
|
window.addEventListener("toggle-debug-panel", handler);
|
||||||
|
return () => window.removeEventListener("toggle-debug-panel", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
<NativeWindowThemeBridge />
|
||||||
{/* Global macOS titlebar drag region — sits above all content */}
|
{/* Global macOS titlebar drag region — sits above all content */}
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className="fixed inset-x-0 top-0 z-[9999] h-[var(--titlebar-height)]"
|
className="fixed inset-x-0 top-0 z-[9999] h-[var(--titlebar-height)]"
|
||||||
/>
|
/>
|
||||||
{projectRoot ? <WorkspaceWithClaude /> : <ProjectPicker />}
|
{projectRoot ? <WorkspaceWithClaude /> : <ProjectPicker />}
|
||||||
|
<EnvironmentOnboarding />
|
||||||
|
{showDebug && (
|
||||||
|
<div className="fixed inset-0 z-[9998] flex items-end justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/20"
|
||||||
|
onClick={() => setShowDebug(false)}
|
||||||
|
/>
|
||||||
|
<div className="relative h-[60vh] w-full border-border border-t bg-background shadow-lg">
|
||||||
|
<div className="flex h-8 items-center justify-between border-border border-b bg-muted/50 px-3">
|
||||||
|
<span className="font-medium text-xs">Debug Panel</span>
|
||||||
|
<button
|
||||||
|
className="text-muted-foreground text-xs hover:text-foreground"
|
||||||
|
onClick={() => setShowDebug(false)}
|
||||||
|
>
|
||||||
|
Close (Ctrl+Shift+D)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="h-[calc(60vh-2rem)] overflow-auto">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="p-4 text-muted-foreground text-sm">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LazyDebugPage />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|
|
||||||
184
apps/desktop/src/__tests__/lib/app-zoom.test.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||||
|
import {
|
||||||
|
APP_ZOOM_STORAGE_KEY,
|
||||||
|
DEFAULT_APP_ZOOM,
|
||||||
|
LOCAL_ZOOM_SHORTCUTS_ATTR,
|
||||||
|
MAX_APP_ZOOM,
|
||||||
|
MIN_APP_ZOOM,
|
||||||
|
clampAppZoom,
|
||||||
|
getAppZoomAction,
|
||||||
|
initializeAppZoom,
|
||||||
|
persistAppZoom,
|
||||||
|
readStoredAppZoom,
|
||||||
|
resetAppZoom,
|
||||||
|
shouldHandleAppZoomShortcut,
|
||||||
|
zoomInApp,
|
||||||
|
zoomOutApp,
|
||||||
|
} from "@/lib/app-zoom";
|
||||||
|
|
||||||
|
describe("app zoom", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the default zoom when storage is empty or invalid", () => {
|
||||||
|
expect(readStoredAppZoom()).toBe(DEFAULT_APP_ZOOM);
|
||||||
|
|
||||||
|
localStorage.setItem(APP_ZOOM_STORAGE_KEY, "not-a-number");
|
||||||
|
expect(readStoredAppZoom()).toBe(DEFAULT_APP_ZOOM);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps stored zoom values into the supported range", () => {
|
||||||
|
localStorage.setItem(APP_ZOOM_STORAGE_KEY, "999");
|
||||||
|
expect(readStoredAppZoom()).toBe(MAX_APP_ZOOM);
|
||||||
|
|
||||||
|
localStorage.setItem(APP_ZOOM_STORAGE_KEY, "0.1");
|
||||||
|
expect(readStoredAppZoom()).toBe(MIN_APP_ZOOM);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies persisted zoom through the webview API", async () => {
|
||||||
|
const webview = getCurrentWebview();
|
||||||
|
|
||||||
|
await persistAppZoom(1.25);
|
||||||
|
|
||||||
|
expect(webview.setZoom).toHaveBeenCalledWith(1.25);
|
||||||
|
expect(localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe("1.25");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets stale global zoom on startup", async () => {
|
||||||
|
const webview = getCurrentWebview();
|
||||||
|
localStorage.setItem(APP_ZOOM_STORAGE_KEY, "1.4");
|
||||||
|
|
||||||
|
await initializeAppZoom();
|
||||||
|
|
||||||
|
expect(webview.setZoom).toHaveBeenCalledWith(DEFAULT_APP_ZOOM);
|
||||||
|
expect(localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zooms in, zooms out, and resets around the stored value", async () => {
|
||||||
|
const webview = getCurrentWebview();
|
||||||
|
localStorage.setItem(APP_ZOOM_STORAGE_KEY, "1.2");
|
||||||
|
|
||||||
|
await zoomInApp();
|
||||||
|
expect(webview.setZoom).toHaveBeenLastCalledWith(1.3);
|
||||||
|
expect(localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe("1.3");
|
||||||
|
|
||||||
|
await zoomOutApp();
|
||||||
|
expect(webview.setZoom).toHaveBeenLastCalledWith(1.2);
|
||||||
|
expect(localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe("1.2");
|
||||||
|
|
||||||
|
await resetAppZoom();
|
||||||
|
expect(webview.setZoom).toHaveBeenLastCalledWith(DEFAULT_APP_ZOOM);
|
||||||
|
expect(localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe(
|
||||||
|
DEFAULT_APP_ZOOM.toString(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds and clamps zoom values consistently", () => {
|
||||||
|
expect(clampAppZoom(1.234)).toBe(1.23);
|
||||||
|
expect(clampAppZoom(10)).toBe(MAX_APP_ZOOM);
|
||||||
|
expect(clampAppZoom(0.01)).toBe(MIN_APP_ZOOM);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAppZoomAction", () => {
|
||||||
|
it("detects zoom-in shortcuts", () => {
|
||||||
|
expect(
|
||||||
|
getAppZoomAction({
|
||||||
|
metaKey: true,
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
key: "+",
|
||||||
|
code: "Equal",
|
||||||
|
}),
|
||||||
|
).toBe("in");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getAppZoomAction({
|
||||||
|
metaKey: false,
|
||||||
|
ctrlKey: true,
|
||||||
|
altKey: false,
|
||||||
|
key: "=",
|
||||||
|
code: "Equal",
|
||||||
|
}),
|
||||||
|
).toBe("in");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects zoom-out shortcuts", () => {
|
||||||
|
expect(
|
||||||
|
getAppZoomAction({
|
||||||
|
metaKey: true,
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
key: "-",
|
||||||
|
code: "Minus",
|
||||||
|
}),
|
||||||
|
).toBe("out");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getAppZoomAction({
|
||||||
|
metaKey: false,
|
||||||
|
ctrlKey: true,
|
||||||
|
altKey: false,
|
||||||
|
key: "_",
|
||||||
|
code: "Minus",
|
||||||
|
}),
|
||||||
|
).toBe("out");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects zoom reset shortcuts", () => {
|
||||||
|
expect(
|
||||||
|
getAppZoomAction({
|
||||||
|
metaKey: true,
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
key: "0",
|
||||||
|
code: "Digit0",
|
||||||
|
}),
|
||||||
|
).toBe("reset");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unrelated shortcuts", () => {
|
||||||
|
expect(
|
||||||
|
getAppZoomAction({
|
||||||
|
metaKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
key: "+",
|
||||||
|
code: "Equal",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getAppZoomAction({
|
||||||
|
metaKey: true,
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: true,
|
||||||
|
key: "+",
|
||||||
|
code: "Equal",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldHandleAppZoomShortcut", () => {
|
||||||
|
it("handles global zoom when the target is outside a local zoom surface", () => {
|
||||||
|
const target = document.createElement("div");
|
||||||
|
expect(shouldHandleAppZoomShortcut(target)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips global zoom inside local zoom surfaces", () => {
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.setAttribute(LOCAL_ZOOM_SHORTCUTS_ATTR, "true");
|
||||||
|
const target = document.createElement("button");
|
||||||
|
wrapper.appendChild(target);
|
||||||
|
|
||||||
|
expect(shouldHandleAppZoomShortcut(target)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to handling zoom for non-element targets", () => {
|
||||||
|
expect(shouldHandleAppZoomShortcut(null)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
67
apps/desktop/src/__tests__/lib/model-capabilities.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getModelCapabilities,
|
||||||
|
isChatModelOption,
|
||||||
|
} from "@/lib/model-capabilities";
|
||||||
|
|
||||||
|
describe("model capabilities", () => {
|
||||||
|
it("uses provider metadata for vision capability", () => {
|
||||||
|
expect(
|
||||||
|
getModelCapabilities({
|
||||||
|
model: "custom-model",
|
||||||
|
metadata: { model_info: { supports_vision: true } },
|
||||||
|
}).vision,
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getModelCapabilities({
|
||||||
|
label: "Qwen",
|
||||||
|
model: "qwen3.6-flash",
|
||||||
|
metadata: { supports_vision: false },
|
||||||
|
}).vision,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes recent Qwen chat models as vision-capable when metadata is missing", () => {
|
||||||
|
expect(
|
||||||
|
getModelCapabilities({
|
||||||
|
label: "Qwen",
|
||||||
|
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
model: "qwen3.5-flash",
|
||||||
|
}).vision,
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getModelCapabilities({
|
||||||
|
label: "Qwen",
|
||||||
|
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
model: "qwen3.6-flash",
|
||||||
|
}).vision,
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getModelCapabilities({
|
||||||
|
label: "Qwen",
|
||||||
|
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
model: "qwen3.6-plus",
|
||||||
|
}).vision,
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getModelCapabilities({
|
||||||
|
label: "Qwen",
|
||||||
|
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
model: "qwen3.5-coder",
|
||||||
|
}).vision,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters non-chat model families", () => {
|
||||||
|
expect(
|
||||||
|
isChatModelOption({
|
||||||
|
label: "Qwen",
|
||||||
|
model: "text-embedding-v4",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
52
apps/desktop/src/__tests__/lib/project-attachments.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
copyFileToProject: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/tauri/fs", () => ({
|
||||||
|
copyFileToProject: mocks.copyFileToProject,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildReferenceFilesSection,
|
||||||
|
importReferenceFiles,
|
||||||
|
} from "@/lib/project-attachments";
|
||||||
|
|
||||||
|
describe("project attachment helpers", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.copyFileToProject.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("imports PDFs without creating extracted text files", async () => {
|
||||||
|
mocks.copyFileToProject.mockResolvedValueOnce("attachments/paper.pdf");
|
||||||
|
|
||||||
|
const files = await importReferenceFiles("C:/project", [
|
||||||
|
"C:/source/paper.pdf",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(mocks.copyFileToProject).toHaveBeenCalledWith(
|
||||||
|
"C:/project",
|
||||||
|
"C:/source/paper.pdf",
|
||||||
|
"attachments/paper.pdf",
|
||||||
|
);
|
||||||
|
expect(files).toEqual([
|
||||||
|
{
|
||||||
|
relativePath: "attachments/paper.pdf",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a prompt section that keeps PDF references as PDFs", () => {
|
||||||
|
const section = buildReferenceFilesSection([
|
||||||
|
{ relativePath: "attachments/paper.pdf" },
|
||||||
|
{ relativePath: "attachments/data.csv" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(section).toContain("### Reference Files");
|
||||||
|
expect(section).toContain("`attachments/paper.pdf` (PDF)");
|
||||||
|
expect(section).toContain("`attachments/data.csv`");
|
||||||
|
expect(section).not.toContain("extracted text");
|
||||||
|
expect(section).not.toContain(".pdf.txt");
|
||||||
|
});
|
||||||
|
});
|
||||||
46
apps/desktop/src/__tests__/lib/provider-icons.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getProviderDisplayName,
|
||||||
|
getProviderIconSrc,
|
||||||
|
} from "@/lib/provider-icons";
|
||||||
|
|
||||||
|
describe("getProviderDisplayName", () => {
|
||||||
|
it("derives provider names from old custom labels", () => {
|
||||||
|
expect(
|
||||||
|
getProviderDisplayName({
|
||||||
|
label: "Custom OpenAI API",
|
||||||
|
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
model: "qwen3.7-plus",
|
||||||
|
}),
|
||||||
|
).toBe("Qwen");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getProviderDisplayName({
|
||||||
|
label: "Custom OpenAI API",
|
||||||
|
baseUrl: "https://open.bigmodel.cn/api/paas/v4",
|
||||||
|
model: "glm-5.1",
|
||||||
|
}),
|
||||||
|
).toBe("GLM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps meaningful provider labels for unknown endpoints", () => {
|
||||||
|
expect(
|
||||||
|
getProviderDisplayName({
|
||||||
|
label: "Acme AI",
|
||||||
|
baseUrl: "https://models.example.test/v1",
|
||||||
|
model: "acme-large",
|
||||||
|
}),
|
||||||
|
).toBe("Acme AI");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes local Ollama endpoints", () => {
|
||||||
|
const provider = {
|
||||||
|
label: "Custom OpenAI API",
|
||||||
|
baseUrl: "http://localhost:11434/v1",
|
||||||
|
model: "llama3.2",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getProviderDisplayName(provider)).toBe("Ollama");
|
||||||
|
expect(getProviderIconSrc(provider)).toContain("ollama");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -33,7 +33,7 @@ describe("Tauri IPC error message extraction", () => {
|
||||||
// This is what Tauri invoke() rejects with for Rust Err(String)
|
// This is what Tauri invoke() rejects with for Rust Err(String)
|
||||||
const error = "Compilation failed\n\n! Undefined control sequence.";
|
const error = "Compilation failed\n\n! Undefined control sequence.";
|
||||||
expect(extractErrorMessage(error)).toBe(
|
expect(extractErrorMessage(error)).toBe(
|
||||||
"Compilation failed\n\n! Undefined control sequence."
|
"Compilation failed\n\n! Undefined control sequence.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,110 +1,109 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { readDir, stat } from "@tauri-apps/plugin-fs";
|
||||||
|
import {
|
||||||
|
getProjectFileType,
|
||||||
|
scanProjectFolder,
|
||||||
|
shouldSkipProjectDirectory,
|
||||||
|
} from "@/lib/tauri/fs";
|
||||||
|
|
||||||
// getFileType is not exported, so we test via the module's behavior.
|
describe("tauri fs helpers", () => {
|
||||||
// We need to import from the source and test the classification logic.
|
beforeEach(() => {
|
||||||
// Since getFileType is private, we'll extract the logic into a testable pattern.
|
vi.clearAllMocks();
|
||||||
// For now, test the exported types and the classification indirectly.
|
|
||||||
|
|
||||||
// We can test the file type classification logic by reimplementing the same
|
|
||||||
// pattern as the source and verifying consistency, or we test via scanProjectFolder.
|
|
||||||
// Since scanProjectFolder requires Tauri filesystem mocks with complex async behavior,
|
|
||||||
// let's test the pure classification logic directly by accessing the private function
|
|
||||||
// via a small wrapper test.
|
|
||||||
|
|
||||||
// Actually, the simplest approach: the getFileType function is module-private.
|
|
||||||
// We'll test it by examining the constants and logic as documented.
|
|
||||||
|
|
||||||
describe("getFileType logic", () => {
|
|
||||||
// Replicate the classification logic for testing
|
|
||||||
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".bmp", ".webp"]);
|
|
||||||
const STYLE_EXTENSIONS = new Set([".sty", ".cls", ".bst", ".def", ".cfg", ".fd", ".dtx", ".ins"]);
|
|
||||||
const IGNORED_EXTENSIONS = new Set([
|
|
||||||
".aux", ".log", ".out", ".toc", ".lof", ".lot", ".fls",
|
|
||||||
".fdb_latexmk", ".synctex.gz", ".synctex", ".blg", ".bbl",
|
|
||||||
".nav", ".snm", ".vrb", ".run.xml", ".bcf",
|
|
||||||
// Binary / non-text files
|
|
||||||
".hwp", ".hwpx", ".doc", ".docx", ".xls", ".xlsx", ".xlsm",
|
|
||||||
".ppt", ".pptx", ".accdb", ".mdb",
|
|
||||||
".zip", ".rar", ".7z", ".tar", ".gz",
|
|
||||||
".exe", ".dll", ".so", ".dylib", ".o", ".obj",
|
|
||||||
".bin", ".dat", ".iso", ".dmg", ".msi",
|
|
||||||
".mp3", ".mp4", ".avi", ".mov", ".mkv", ".wav", ".flac",
|
|
||||||
".psd", ".ai", ".sketch", ".fig",
|
|
||||||
".sqlite", ".db",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function getFileType(name: string): string | null {
|
|
||||||
const lower = name.toLowerCase();
|
|
||||||
for (const ext of IGNORED_EXTENSIONS) {
|
|
||||||
if (lower.endsWith(ext)) return null;
|
|
||||||
}
|
|
||||||
if (lower.endsWith(".tex") || lower.endsWith(".ltx")) return "tex";
|
|
||||||
if (lower.endsWith(".bib")) return "bib";
|
|
||||||
if (lower.endsWith(".pdf")) return "pdf";
|
|
||||||
for (const ext of IMAGE_EXTENSIONS) {
|
|
||||||
if (lower.endsWith(ext)) return "image";
|
|
||||||
}
|
|
||||||
for (const ext of STYLE_EXTENSIONS) {
|
|
||||||
if (lower.endsWith(ext)) return "style";
|
|
||||||
}
|
|
||||||
return "other";
|
|
||||||
}
|
|
||||||
|
|
||||||
it("classifies .tex files", () => {
|
|
||||||
expect(getFileType("main.tex")).toBe("tex");
|
|
||||||
expect(getFileType("chapter.TEX")).toBe("tex");
|
|
||||||
expect(getFileType("doc.ltx")).toBe("tex");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("classifies .bib files", () => {
|
describe("getProjectFileType", () => {
|
||||||
expect(getFileType("refs.bib")).toBe("bib");
|
it("classifies editable project files", () => {
|
||||||
|
expect(getProjectFileType("main.tex")).toBe("tex");
|
||||||
|
expect(getProjectFileType("chapter.TEX")).toBe("tex");
|
||||||
|
expect(getProjectFileType("refs.bib")).toBe("bib");
|
||||||
|
expect(getProjectFileType("output.pdf")).toBe("pdf");
|
||||||
|
expect(getProjectFileType("figure.png")).toBe("image");
|
||||||
|
expect(getProjectFileType("custom.sty")).toBe("style");
|
||||||
|
expect(getProjectFileType("notes.md")).toBe("other");
|
||||||
|
expect(getProjectFileType("script.py")).toBe("other");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores LaTeX and compiled build artifacts", () => {
|
||||||
|
expect(getProjectFileType("main.aux")).toBeNull();
|
||||||
|
expect(getProjectFileType("main.synctex.gz")).toBeNull();
|
||||||
|
expect(getProjectFileType("module.pyc")).toBeNull();
|
||||||
|
expect(getProjectFileType("native.pyd")).toBeNull();
|
||||||
|
expect(getProjectFileType("libnative.so")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps imported files with arbitrary extensions visible", () => {
|
||||||
|
expect(getProjectFileType("archive.zip")).toBe("other");
|
||||||
|
expect(getProjectFileType("paper.docx")).toBe("other");
|
||||||
|
expect(getProjectFileType("data.xlsx")).toBe("other");
|
||||||
|
expect(getProjectFileType("movie.mp4")).toBe("other");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("classifies .pdf files", () => {
|
describe("shouldSkipProjectDirectory", () => {
|
||||||
expect(getFileType("output.pdf")).toBe("pdf");
|
it("skips hidden and generated dependency directories", () => {
|
||||||
|
expect(shouldSkipProjectDirectory(".git")).toBe(true);
|
||||||
|
expect(shouldSkipProjectDirectory(".venv")).toBe(true);
|
||||||
|
expect(shouldSkipProjectDirectory("node_modules")).toBe(true);
|
||||||
|
expect(shouldSkipProjectDirectory("__pycache__")).toBe(true);
|
||||||
|
expect(shouldSkipProjectDirectory("venv")).toBe(true);
|
||||||
|
expect(shouldSkipProjectDirectory("ENV")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps normal project folders visible", () => {
|
||||||
|
expect(shouldSkipProjectDirectory("chapters")).toBe(false);
|
||||||
|
expect(shouldSkipProjectDirectory("figures")).toBe(false);
|
||||||
|
expect(shouldSkipProjectDirectory("attachments")).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("classifies image files", () => {
|
describe("scanProjectFolder", () => {
|
||||||
expect(getFileType("fig.png")).toBe("image");
|
it("does not recurse into generated cache directories", async () => {
|
||||||
expect(getFileType("photo.jpg")).toBe("image");
|
vi.mocked(readDir).mockImplementation(async (dir: string | URL) => {
|
||||||
expect(getFileType("icon.svg")).toBe("image");
|
const dirPath = String(dir);
|
||||||
expect(getFileType("anim.gif")).toBe("image");
|
if (dirPath === "/project") {
|
||||||
expect(getFileType("pic.webp")).toBe("image");
|
return [
|
||||||
});
|
{ name: "__pycache__", isDirectory: true },
|
||||||
|
{ name: "node_modules", isDirectory: true },
|
||||||
|
{ name: "main.tex", isDirectory: false },
|
||||||
|
{ name: "chapters", isDirectory: true },
|
||||||
|
] as any;
|
||||||
|
}
|
||||||
|
|
||||||
it("classifies style files", () => {
|
if (dirPath === "/project/chapters") {
|
||||||
expect(getFileType("custom.sty")).toBe("style");
|
return [{ name: "intro.tex", isDirectory: false }] as any;
|
||||||
expect(getFileType("report.cls")).toBe("style");
|
}
|
||||||
expect(getFileType("plain.bst")).toBe("style");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores build artifacts", () => {
|
throw new Error(`Unexpected readDir path: ${dirPath}`);
|
||||||
expect(getFileType("main.aux")).toBeNull();
|
});
|
||||||
expect(getFileType("main.log")).toBeNull();
|
|
||||||
expect(getFileType("main.toc")).toBeNull();
|
|
||||||
expect(getFileType("main.synctex.gz")).toBeNull();
|
|
||||||
expect(getFileType("main.fdb_latexmk")).toBeNull();
|
|
||||||
expect(getFileType("main.bbl")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores binary and non-text files", () => {
|
const result = await scanProjectFolder("/project");
|
||||||
expect(getFileType("document.docx")).toBeNull();
|
|
||||||
expect(getFileType("spreadsheet.xlsx")).toBeNull();
|
|
||||||
expect(getFileType("report.hwp")).toBeNull();
|
|
||||||
expect(getFileType("data.accdb")).toBeNull();
|
|
||||||
expect(getFileType("archive.zip")).toBeNull();
|
|
||||||
expect(getFileType("app.exe")).toBeNull();
|
|
||||||
expect(getFileType("song.mp3")).toBeNull();
|
|
||||||
expect(getFileType("video.mp4")).toBeNull();
|
|
||||||
expect(getFileType("image.psd")).toBeNull();
|
|
||||||
expect(getFileType("database.sqlite")).toBeNull();
|
|
||||||
expect(getFileType("library.dll")).toBeNull();
|
|
||||||
expect(getFileType("presentation.pptx")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies unknown extensions as other", () => {
|
expect(readDir).toHaveBeenCalledWith("/project");
|
||||||
expect(getFileType("readme.txt")).toBe("other");
|
expect(readDir).toHaveBeenCalledWith("/project/chapters");
|
||||||
expect(getFileType("notes.md")).toBe("other");
|
expect(readDir).not.toHaveBeenCalledWith("/project/__pycache__");
|
||||||
expect(getFileType("data.csv")).toBe("other");
|
expect(readDir).not.toHaveBeenCalledWith("/project/node_modules");
|
||||||
|
expect(result.folders).toEqual(["chapters"]);
|
||||||
|
expect(result.files.map((file) => file.relativePath)).toEqual([
|
||||||
|
"main.tex",
|
||||||
|
"chapters/intro.tex",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps arbitrary file formats visible as other files", async () => {
|
||||||
|
vi.mocked(readDir).mockResolvedValue([
|
||||||
|
{ name: "worker.py", isDirectory: false },
|
||||||
|
{ name: "notes.txt", isDirectory: false },
|
||||||
|
] as any);
|
||||||
|
vi.mocked(stat).mockResolvedValue({ size: 128 } as any);
|
||||||
|
|
||||||
|
const result = await scanProjectFolder("/project");
|
||||||
|
|
||||||
|
expect(result.files.map((file) => file.relativePath)).toEqual([
|
||||||
|
"worker.py",
|
||||||
|
"notes.txt",
|
||||||
|
]);
|
||||||
|
expect(stat).toHaveBeenCalledTimes(2);
|
||||||
|
expect(result.files.every((file) => file.type === "other")).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,12 @@ describe("template-registry", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns templates for each category", () => {
|
it("returns templates for each category", () => {
|
||||||
for (const cat of ["academic", "professional", "creative", "starter"] as const) {
|
for (const cat of [
|
||||||
|
"academic",
|
||||||
|
"professional",
|
||||||
|
"creative",
|
||||||
|
"starter",
|
||||||
|
] as const) {
|
||||||
expect(getTemplatesByCategory(cat).length).toBeGreaterThan(0);
|
expect(getTemplatesByCategory(cat).length).toBeGreaterThan(0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,54 @@
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
const storageData = new Map<string, string>();
|
||||||
|
const mockStorage = {
|
||||||
|
getItem: vi.fn((key: string) => storageData.get(key) ?? null),
|
||||||
|
setItem: vi.fn((key: string, value: string) => {
|
||||||
|
storageData.set(key, value);
|
||||||
|
}),
|
||||||
|
removeItem: vi.fn((key: string) => {
|
||||||
|
storageData.delete(key);
|
||||||
|
}),
|
||||||
|
clear: vi.fn(() => {
|
||||||
|
storageData.clear();
|
||||||
|
}),
|
||||||
|
key: vi.fn((index: number) => Array.from(storageData.keys())[index] ?? null),
|
||||||
|
get length() {
|
||||||
|
return storageData.size;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWebview = {
|
||||||
|
setZoom: vi.fn(() => Promise.resolve()),
|
||||||
|
onDragDropEvent: vi.fn(() => Promise.resolve(() => {})),
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(window, "localStorage", {
|
||||||
|
value: mockStorage,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, "localStorage", {
|
||||||
|
value: mockStorage,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
// Mock @tauri-apps/api/core
|
// Mock @tauri-apps/api/core
|
||||||
vi.mock("@tauri-apps/api/core", () => ({
|
vi.mock("@tauri-apps/api/core", () => ({
|
||||||
invoke: vi.fn(),
|
invoke: vi.fn(),
|
||||||
convertFileSrc: vi.fn((path: string) => `asset://localhost/${path}`),
|
convertFileSrc: vi.fn((path: string) => `asset://localhost/${path}`),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock @tauri-apps/api/event
|
||||||
|
vi.mock("@tauri-apps/api/event", () => ({
|
||||||
|
emit: vi.fn(() => Promise.resolve()),
|
||||||
|
listen: vi.fn(() => Promise.resolve(() => {})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock @tauri-apps/api/webview
|
||||||
|
vi.mock("@tauri-apps/api/webview", () => ({
|
||||||
|
getCurrentWebview: vi.fn(() => mockWebview),
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock @tauri-apps/api/path
|
// Mock @tauri-apps/api/path
|
||||||
vi.mock("@tauri-apps/api/path", () => ({
|
vi.mock("@tauri-apps/api/path", () => ({
|
||||||
join: vi.fn((...args: string[]) => Promise.resolve(args.join("/"))),
|
join: vi.fn((...args: string[]) => Promise.resolve(args.join("/"))),
|
||||||
|
|
@ -16,6 +59,7 @@ vi.mock("@tauri-apps/plugin-fs", () => ({
|
||||||
readTextFile: vi.fn(),
|
readTextFile: vi.fn(),
|
||||||
writeTextFile: vi.fn(),
|
writeTextFile: vi.fn(),
|
||||||
readDir: vi.fn(),
|
readDir: vi.fn(),
|
||||||
|
stat: vi.fn(),
|
||||||
exists: vi.fn(),
|
exists: vi.fn(),
|
||||||
mkdir: vi.fn(),
|
mkdir: vi.fn(),
|
||||||
readFile: vi.fn(),
|
readFile: vi.fn(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,444 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
const { mockDocumentState, getDocumentState, createSnapshotMock } = vi.hoisted(
|
||||||
|
() => ({
|
||||||
|
mockDocumentState: {} as any,
|
||||||
|
getDocumentState: vi.fn(),
|
||||||
|
createSnapshotMock: vi.fn(() => Promise.resolve(null)),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("@/stores/document-store", () => ({
|
||||||
|
useDocumentStore: {
|
||||||
|
getState: getDocumentState,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/history-store", () => ({
|
||||||
|
useHistoryStore: {
|
||||||
|
getState: vi.fn(() => ({
|
||||||
|
createSnapshot: createSnapshotMock,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
CLAUDE_CODE_PROVIDER_ID,
|
||||||
|
useClaudeChatStore,
|
||||||
|
} from "@/stores/claude-chat-store";
|
||||||
|
|
||||||
|
function resetClaudeChatStore() {
|
||||||
|
useClaudeChatStore.setState({
|
||||||
|
messages: [],
|
||||||
|
sessionId: null,
|
||||||
|
isStreaming: false,
|
||||||
|
streamingStartedAt: null,
|
||||||
|
error: null,
|
||||||
|
totalInputTokens: 0,
|
||||||
|
totalOutputTokens: 0,
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
id: "tab-default",
|
||||||
|
title: "New Chat",
|
||||||
|
projectPath: "/project",
|
||||||
|
sessionId: null,
|
||||||
|
providerKey: CLAUDE_CODE_PROVIDER_ID,
|
||||||
|
sessionProviderKey: null,
|
||||||
|
messages: [],
|
||||||
|
isStreaming: false,
|
||||||
|
streamingStartedAt: null,
|
||||||
|
error: null,
|
||||||
|
totalInputTokens: 0,
|
||||||
|
totalOutputTokens: 0,
|
||||||
|
draft: { input: "", pinnedContexts: [] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeTabId: "tab-default",
|
||||||
|
activeProjectPath: "/project",
|
||||||
|
pendingInitialPrompt: null,
|
||||||
|
pendingAttachments: [],
|
||||||
|
pendingPinnedContextRemovalLabels: [],
|
||||||
|
selectedModel: "opus",
|
||||||
|
selectedProviderCredentialId: CLAUDE_CODE_PROVIDER_ID,
|
||||||
|
selectedProviderModels: {},
|
||||||
|
effortLevel: "medium",
|
||||||
|
_cancelledByUser: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMockDocumentState(overrides: Partial<any> = {}) {
|
||||||
|
const content = ["Line 1", "Line 2", "Line 3", "Line 4"].join("\n");
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
projectRoot: "/project",
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
id: "main.tex",
|
||||||
|
name: "main.tex",
|
||||||
|
relativePath: "main.tex",
|
||||||
|
absolutePath: "/project/main.tex",
|
||||||
|
type: "tex",
|
||||||
|
content,
|
||||||
|
isDirty: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeFileId: "main.tex",
|
||||||
|
selectionRange: null,
|
||||||
|
saveAllFiles: vi.fn(() => Promise.resolve()),
|
||||||
|
refreshFiles: vi.fn(() => Promise.resolve()),
|
||||||
|
reloadFile: vi.fn(() => Promise.resolve()),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.keys(mockDocumentState).forEach(
|
||||||
|
(key) => delete mockDocumentState[key],
|
||||||
|
);
|
||||||
|
Object.assign(mockDocumentState, state);
|
||||||
|
getDocumentState.mockImplementation(() => mockDocumentState);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useClaudeChatStore.sendPrompt context assembly", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
resetClaudeChatStore();
|
||||||
|
setMockDocumentState();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a plain file label and full file content for whole-file mentions", async () => {
|
||||||
|
const wholeFileText =
|
||||||
|
"\\section{Intro}\nThis is the full file.\n\\textbf{Important note}";
|
||||||
|
|
||||||
|
await useClaudeChatStore.getState().sendPrompt("Please revise this", {
|
||||||
|
label: "@main.tex",
|
||||||
|
filePath: "main.tex",
|
||||||
|
selectedText: wholeFileText,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith(
|
||||||
|
"execute_claude_code",
|
||||||
|
expect.objectContaining({
|
||||||
|
projectPath: "/project",
|
||||||
|
tabId: "tab-default",
|
||||||
|
prompt: expect.stringContaining("[Selection: @main.tex]"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const prompt = (vi.mocked(invoke).mock.calls[0]?.[1] as any)
|
||||||
|
?.prompt as string;
|
||||||
|
expect(prompt).toContain("[Currently open file: main.tex]");
|
||||||
|
expect(prompt).toContain("[Selection: @main.tex]");
|
||||||
|
expect(prompt).toContain(wholeFileText);
|
||||||
|
|
||||||
|
const userText =
|
||||||
|
useClaudeChatStore.getState().messages[0].message?.content?.[0].text;
|
||||||
|
expect(userText).toBe("@main.tex\nPlease revise this");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a line-range label and only the selected slice for selection context", async () => {
|
||||||
|
const state = setMockDocumentState({
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
id: "main.tex",
|
||||||
|
name: "main.tex",
|
||||||
|
relativePath: "main.tex",
|
||||||
|
absolutePath: "/project/main.tex",
|
||||||
|
type: "tex",
|
||||||
|
content: "alpha\nbeta\ngamma\ndelta",
|
||||||
|
isDirty: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionRange: { start: 6, end: 16 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await useClaudeChatStore.getState().sendPrompt("Please revise this");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith(
|
||||||
|
"execute_claude_code",
|
||||||
|
expect.objectContaining({
|
||||||
|
projectPath: "/project",
|
||||||
|
tabId: "tab-default",
|
||||||
|
prompt: expect.stringContaining("[Selection: @main.tex:2:1-3:6]"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const prompt = (vi.mocked(invoke).mock.calls[0]?.[1] as any)
|
||||||
|
?.prompt as string;
|
||||||
|
expect(prompt).toContain("[Currently open file: main.tex]");
|
||||||
|
expect(prompt).toContain("[Selection: @main.tex:2:1-3:6]");
|
||||||
|
expect(prompt).toContain("[Selected text:\nbeta\ngamma\n]");
|
||||||
|
expect(prompt).not.toContain("alpha\na");
|
||||||
|
expect(prompt).not.toContain("\ndelta");
|
||||||
|
|
||||||
|
const userText =
|
||||||
|
useClaudeChatStore.getState().messages[0].message?.content?.[0].text;
|
||||||
|
expect(userText).toBe("@main.tex:2:1-3:6\nPlease revise this");
|
||||||
|
expect(state.saveAllFiles).not.toHaveBeenCalled();
|
||||||
|
expect(createSnapshotMock).toHaveBeenCalledWith(
|
||||||
|
"/project",
|
||||||
|
"[claude] Before Claude edit",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends Claude Code when the Claude provider option is selected", async () => {
|
||||||
|
useClaudeChatStore.setState({
|
||||||
|
selectedProviderCredentialId: CLAUDE_CODE_PROVIDER_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
await useClaudeChatStore.getState().sendPrompt("Use Claude");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith(
|
||||||
|
"execute_claude_code",
|
||||||
|
expect.objectContaining({
|
||||||
|
providerCredentialId: null,
|
||||||
|
providerModelOverride: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts Claude Code with prior context when switching from a direct provider", async () => {
|
||||||
|
useClaudeChatStore.setState((state) => ({
|
||||||
|
sessionId: "qwen-session",
|
||||||
|
selectedProviderCredentialId: CLAUDE_CODE_PROVIDER_ID,
|
||||||
|
tabs: state.tabs.map((tab) =>
|
||||||
|
tab.id === "tab-default"
|
||||||
|
? {
|
||||||
|
...tab,
|
||||||
|
sessionId: "qwen-session",
|
||||||
|
providerKey: CLAUDE_CODE_PROVIDER_ID,
|
||||||
|
sessionProviderKey: "openai-compatible:qwen-cred",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
message: {
|
||||||
|
content: [{ type: "text", text: "Old DS question" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
content: [{ type: "text", text: "Old DS answer" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: tab,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await useClaudeChatStore.getState().sendPrompt("Use Claude now");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith(
|
||||||
|
"execute_claude_code",
|
||||||
|
expect.objectContaining({
|
||||||
|
providerCredentialId: null,
|
||||||
|
providerModelOverride: null,
|
||||||
|
prompt: expect.stringContaining("[Provider switch context]"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const prompt = (vi.mocked(invoke).mock.calls[0]?.[1] as any).prompt;
|
||||||
|
expect(prompt).toContain("Old DS question");
|
||||||
|
expect(prompt).toContain("Old DS answer");
|
||||||
|
expect(prompt).toContain("Use Claude now");
|
||||||
|
expect(
|
||||||
|
vi
|
||||||
|
.mocked(invoke)
|
||||||
|
.mock.calls.some(([command]) => command === "resume_claude_code"),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the same backend session when switching between OpenAI-compatible providers", async () => {
|
||||||
|
useClaudeChatStore.setState((state) => ({
|
||||||
|
sessionId: "shared-session",
|
||||||
|
selectedProviderCredentialId: "deepseek-cred",
|
||||||
|
selectedProviderModels: { "deepseek-cred": "deepseek-chat" },
|
||||||
|
tabs: state.tabs.map((tab) =>
|
||||||
|
tab.id === "tab-default"
|
||||||
|
? {
|
||||||
|
...tab,
|
||||||
|
sessionId: "shared-session",
|
||||||
|
providerKey: "openai-compatible:deepseek-cred",
|
||||||
|
sessionProviderKey: "openai-compatible:qwen-cred",
|
||||||
|
}
|
||||||
|
: tab,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await useClaudeChatStore.getState().sendPrompt("Use DeepSeek now");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith(
|
||||||
|
"resume_claude_code",
|
||||||
|
expect.objectContaining({
|
||||||
|
sessionId: "shared-session",
|
||||||
|
providerCredentialId: "deepseek-cred",
|
||||||
|
providerModelOverride: "deepseek-chat",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes an OpenAI-compatible model override with the provider credential", async () => {
|
||||||
|
useClaudeChatStore.getState().setSelectedProviderCredentialId("qwen-cred");
|
||||||
|
useClaudeChatStore.setState({
|
||||||
|
selectedProviderModels: { "qwen-cred": "qwen3.7-plus" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await useClaudeChatStore.getState().sendPrompt("Use Qwen");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith(
|
||||||
|
"execute_claude_code",
|
||||||
|
expect.objectContaining({
|
||||||
|
providerCredentialId: "qwen-cred",
|
||||||
|
providerModelOverride: "qwen3.7-plus",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useClaudeChatStore.resumeSession", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
resetClaudeChatStore();
|
||||||
|
setMockDocumentState();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores token totals from loaded session history", async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
message: { content: [{ type: "text", text: "hello" }] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
content: [{ type: "text", text: "hi" }],
|
||||||
|
usage: { input_tokens: 11, output_tokens: 7 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
usage: { input_tokens: 13, output_tokens: 5 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await useClaudeChatStore.getState().resumeSession("session-123");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("load_session_history", {
|
||||||
|
projectPath: "/project",
|
||||||
|
sessionId: "session-123",
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = useClaudeChatStore.getState();
|
||||||
|
expect(state.sessionId).toBe("session-123");
|
||||||
|
expect(state.messages).toHaveLength(3);
|
||||||
|
expect(state.totalInputTokens).toBe(24);
|
||||||
|
expect(state.totalOutputTokens).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reuse a tab from another project with the same session id", async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
message: { content: [{ type: "text", text: "from current project" }] },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
useClaudeChatStore.setState((state) => {
|
||||||
|
const baseTab = state.tabs[0];
|
||||||
|
return {
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
...baseTab,
|
||||||
|
id: "tab-current",
|
||||||
|
projectPath: "/project",
|
||||||
|
sessionId: null,
|
||||||
|
messages: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...baseTab,
|
||||||
|
id: "tab-other",
|
||||||
|
title: "Other project",
|
||||||
|
projectPath: "/other-project",
|
||||||
|
sessionId: "shared-session-id",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
message: {
|
||||||
|
content: [{ type: "text", text: "from another project" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeTabId: "tab-current",
|
||||||
|
activeProjectPath: "/project",
|
||||||
|
messages: [],
|
||||||
|
sessionId: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await useClaudeChatStore.getState().resumeSession("shared-session-id");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("load_session_history", {
|
||||||
|
projectPath: "/project",
|
||||||
|
sessionId: "shared-session-id",
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = useClaudeChatStore.getState();
|
||||||
|
const otherProjectTab = state.tabs.find((tab) => tab.id === "tab-other");
|
||||||
|
expect(state.activeTabId).toBe("tab-current");
|
||||||
|
expect(state.activeProjectPath).toBe("/project");
|
||||||
|
expect(state.messages[0].message?.content?.[0].text).toBe(
|
||||||
|
"from current project",
|
||||||
|
);
|
||||||
|
expect(otherProjectTab?.messages[0].message?.content?.[0].text).toBe(
|
||||||
|
"from another project",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides internal file and pasted-image context when restoring history", async () => {
|
||||||
|
const tempImagePath = [
|
||||||
|
"C:\\Temp",
|
||||||
|
"ClaudePrism",
|
||||||
|
"chat-pastes",
|
||||||
|
"1781110224092-1-paste-1781110223586-1.png",
|
||||||
|
].join("\\");
|
||||||
|
const restoredPrompt = [
|
||||||
|
"[Currently open file: main.tex]",
|
||||||
|
"[Selection: Pasted image]",
|
||||||
|
"[Selected text:",
|
||||||
|
`[Temporary pasted image: ${tempImagePath}]`,
|
||||||
|
"Use this image file as visual context for the user's message.",
|
||||||
|
"]",
|
||||||
|
"",
|
||||||
|
"Please inspect this image",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
vi.mocked(invoke).mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
message: {
|
||||||
|
content: restoredPrompt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
content: [{ type: "text", text: "OK" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await useClaudeChatStore.getState().resumeSession("session-with-image");
|
||||||
|
|
||||||
|
const state = useClaudeChatStore.getState();
|
||||||
|
const userContent = state.messages[0].message?.content as any;
|
||||||
|
const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId);
|
||||||
|
|
||||||
|
expect(userContent).toBe("Pasted image\nPlease inspect this image");
|
||||||
|
expect(userContent).not.toContain("[Currently open file:");
|
||||||
|
expect(userContent).not.toContain("[Temporary pasted image:");
|
||||||
|
expect(activeTab?.title).toBe("Please inspect this image");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,17 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import { offsetToLineCol } from "@/stores/claude-chat-store";
|
import {
|
||||||
|
CLAUDE_CODE_PROVIDER_ID,
|
||||||
|
SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY,
|
||||||
|
loadSelectedProviderCredentialId,
|
||||||
|
offsetToLineCol,
|
||||||
|
useClaudeChatStore,
|
||||||
|
} from "@/stores/claude-chat-store";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
useClaudeChatStore.setState({ selectedProviderCredentialId: null });
|
||||||
|
});
|
||||||
|
|
||||||
describe("offsetToLineCol", () => {
|
describe("offsetToLineCol", () => {
|
||||||
it("returns line 1, col 1 for offset 0 on empty string", () => {
|
it("returns line 1, col 1 for offset 0 on empty string", () => {
|
||||||
|
|
@ -38,3 +50,258 @@ describe("offsetToLineCol", () => {
|
||||||
expect(offsetToLineCol("\n\n", 2)).toEqual({ line: 3, col: 1 });
|
expect(offsetToLineCol("\n\n", 2)).toEqual({ line: 3, col: 1 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("provider selection persistence", () => {
|
||||||
|
it("persists Claude Code as an explicit provider selection", () => {
|
||||||
|
useClaudeChatStore
|
||||||
|
.getState()
|
||||||
|
.setSelectedProviderCredentialId(CLAUDE_CODE_PROVIDER_ID);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
sessionStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY),
|
||||||
|
).toBe(CLAUDE_CODE_PROVIDER_ID);
|
||||||
|
expect(
|
||||||
|
localStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY),
|
||||||
|
).toBeNull();
|
||||||
|
expect(loadSelectedProviderCredentialId()).toBe(CLAUDE_CODE_PROVIDER_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists and clears OpenAI-compatible provider selections", () => {
|
||||||
|
useClaudeChatStore.getState().setSelectedProviderCredentialId("qwen");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
sessionStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY),
|
||||||
|
).toBe("qwen");
|
||||||
|
expect(
|
||||||
|
localStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
useClaudeChatStore.getState().setSelectedProviderCredentialId(null);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
sessionStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY),
|
||||||
|
).toBeNull();
|
||||||
|
expect(loadSelectedProviderCredentialId()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps provider selections isolated between chat tabs", () => {
|
||||||
|
const store = useClaudeChatStore.getState();
|
||||||
|
const firstTabId = store.activeTabId;
|
||||||
|
|
||||||
|
store.setSelectedProviderCredentialId("qwen");
|
||||||
|
const secondTabId = store.createTab();
|
||||||
|
useClaudeChatStore.getState().setSelectedProviderCredentialId("gemini");
|
||||||
|
|
||||||
|
expect(useClaudeChatStore.getState().selectedProviderCredentialId).toBe(
|
||||||
|
"gemini",
|
||||||
|
);
|
||||||
|
|
||||||
|
useClaudeChatStore.getState().setActiveTab(firstTabId);
|
||||||
|
expect(useClaudeChatStore.getState().selectedProviderCredentialId).toBe(
|
||||||
|
"qwen",
|
||||||
|
);
|
||||||
|
|
||||||
|
useClaudeChatStore.getState().setActiveTab(secondTabId);
|
||||||
|
expect(useClaudeChatStore.getState().selectedProviderCredentialId).toBe(
|
||||||
|
"gemini",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("project-scoped chat state", () => {
|
||||||
|
it("resets tabs for a new project without clearing a pending initial prompt", () => {
|
||||||
|
useClaudeChatStore.setState((state) => {
|
||||||
|
const baseTab = state.tabs[0];
|
||||||
|
const message = {
|
||||||
|
type: "user" as const,
|
||||||
|
message: { content: [{ type: "text" as const, text: "old project" }] },
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
pendingInitialPrompt: "build this project",
|
||||||
|
pendingAttachments: [
|
||||||
|
{
|
||||||
|
label: "old attachment",
|
||||||
|
filePath: "/project-a/old.png",
|
||||||
|
selectedText: "old",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pendingPinnedContextRemovalLabels: ["@old.tex"],
|
||||||
|
activeProjectPath: "/project-a",
|
||||||
|
activeTabId: "tab-project-a",
|
||||||
|
sessionId: "session-project-a",
|
||||||
|
messages: [message],
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
...baseTab,
|
||||||
|
id: "tab-project-a",
|
||||||
|
title: "Old project chat",
|
||||||
|
projectPath: "/project-a",
|
||||||
|
sessionId: "session-project-a",
|
||||||
|
messages: [message],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
useClaudeChatStore.getState().resetForProject("/project-b");
|
||||||
|
|
||||||
|
const state = useClaudeChatStore.getState();
|
||||||
|
const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId);
|
||||||
|
expect(state.activeProjectPath).toBe("/project-b");
|
||||||
|
expect(activeTab?.projectPath).toBe("/project-b");
|
||||||
|
expect(state.sessionId).toBeNull();
|
||||||
|
expect(state.messages).toEqual([]);
|
||||||
|
expect(state.pendingAttachments).toEqual([]);
|
||||||
|
expect(state.pendingPinnedContextRemovalLabels).toEqual([]);
|
||||||
|
expect(state.pendingInitialPrompt).toBe("build this project");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pinned context removal requests", () => {
|
||||||
|
it("queues and consumes pinned context labels to remove", () => {
|
||||||
|
const chat = useClaudeChatStore.getState();
|
||||||
|
|
||||||
|
chat.requestPinnedContextRemoval(["@main.tex:1:1-1:5"]);
|
||||||
|
chat.requestPinnedContextRemoval(["@main.tex:2:1-2:5"]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
useClaudeChatStore.getState().pendingPinnedContextRemovalLabels,
|
||||||
|
).toEqual(["@main.tex:1:1-1:5", "@main.tex:2:1-2:5"]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
useClaudeChatStore.getState().consumePendingPinnedContextRemovals(),
|
||||||
|
).toEqual(["@main.tex:1:1-1:5", "@main.tex:2:1-2:5"]);
|
||||||
|
expect(
|
||||||
|
useClaudeChatStore.getState().pendingPinnedContextRemovalLabels,
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("queued guidance", () => {
|
||||||
|
it("queues and consumes guidance for the active tab", () => {
|
||||||
|
const chat = useClaudeChatStore.getState();
|
||||||
|
const tabId = chat.activeTabId;
|
||||||
|
|
||||||
|
chat.clearQueuedGuidance(tabId);
|
||||||
|
chat.queueGuidance(tabId, "please focus on the API key deletion flow", {
|
||||||
|
label: "@main.tex:1:1-1:8",
|
||||||
|
filePath: "main.tex",
|
||||||
|
selectedText: "selected",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
useClaudeChatStore.getState().tabs.find((tab) => tab.id === tabId)
|
||||||
|
?.queuedGuidance,
|
||||||
|
).toHaveLength(1);
|
||||||
|
|
||||||
|
const queued = useClaudeChatStore.getState().consumeQueuedGuidance(tabId);
|
||||||
|
expect(queued?.prompt).toBe("please focus on the API key deletion flow");
|
||||||
|
expect(queued?.contextOverride?.filePath).toBe("main.tex");
|
||||||
|
expect(
|
||||||
|
useClaudeChatStore.getState().tabs.find((tab) => tab.id === tabId)
|
||||||
|
?.queuedGuidance,
|
||||||
|
).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can remove and consume a specific queued guidance item", () => {
|
||||||
|
const chat = useClaudeChatStore.getState();
|
||||||
|
const tabId = chat.activeTabId;
|
||||||
|
|
||||||
|
chat.clearQueuedGuidance(tabId);
|
||||||
|
chat.queueGuidance(tabId, "first");
|
||||||
|
chat.queueGuidance(tabId, "second");
|
||||||
|
chat.queueGuidance(tabId, "third");
|
||||||
|
|
||||||
|
const queue = useClaudeChatStore
|
||||||
|
.getState()
|
||||||
|
.tabs.find((tab) => tab.id === tabId)?.queuedGuidance;
|
||||||
|
expect(queue?.map((item) => item.prompt)).toEqual([
|
||||||
|
"first",
|
||||||
|
"second",
|
||||||
|
"third",
|
||||||
|
]);
|
||||||
|
|
||||||
|
chat.removeQueuedGuidance(tabId, queue![1].id);
|
||||||
|
expect(
|
||||||
|
useClaudeChatStore
|
||||||
|
.getState()
|
||||||
|
.tabs.find((tab) => tab.id === tabId)
|
||||||
|
?.queuedGuidance?.map((item) => item.prompt),
|
||||||
|
).toEqual(["first", "third"]);
|
||||||
|
|
||||||
|
const thirdId = useClaudeChatStore
|
||||||
|
.getState()
|
||||||
|
.tabs.find((tab) => tab.id === tabId)?.queuedGuidance?.[1].id;
|
||||||
|
const selected = chat.consumeQueuedGuidance(tabId, thirdId);
|
||||||
|
expect(selected?.prompt).toBe("third");
|
||||||
|
expect(
|
||||||
|
useClaudeChatStore
|
||||||
|
.getState()
|
||||||
|
.tabs.find((tab) => tab.id === tabId)
|
||||||
|
?.queuedGuidance?.map((item) => item.prompt),
|
||||||
|
).toEqual(["first"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks multiple queued guidance items as displayed in chat", () => {
|
||||||
|
const chat = useClaudeChatStore.getState();
|
||||||
|
const tabId = chat.activeTabId;
|
||||||
|
|
||||||
|
chat.clearQueuedGuidance(tabId);
|
||||||
|
chat.queueGuidance(tabId, "first");
|
||||||
|
chat.queueGuidance(tabId, "second");
|
||||||
|
chat.queueGuidance(tabId, "third");
|
||||||
|
|
||||||
|
const queue = useClaudeChatStore
|
||||||
|
.getState()
|
||||||
|
.tabs.find((tab) => tab.id === tabId)?.queuedGuidance;
|
||||||
|
const secondId = queue?.[1].id;
|
||||||
|
const thirdId = queue?.[2].id;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
useClaudeChatStore
|
||||||
|
.getState()
|
||||||
|
.displayQueuedGuidanceInChat(tabId, secondId),
|
||||||
|
).toBe(secondId);
|
||||||
|
expect(
|
||||||
|
useClaudeChatStore.getState().displayQueuedGuidanceInChat(tabId, thirdId),
|
||||||
|
).toBe(thirdId);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
useClaudeChatStore
|
||||||
|
.getState()
|
||||||
|
.tabs.find((tab) => tab.id === tabId)
|
||||||
|
?.queuedGuidance?.map((item) => ({
|
||||||
|
prompt: item.prompt,
|
||||||
|
displayedInChat: item.displayedInChat ?? false,
|
||||||
|
})),
|
||||||
|
).toEqual([
|
||||||
|
{ prompt: "first", displayedInChat: false },
|
||||||
|
{ prompt: "second", displayedInChat: true },
|
||||||
|
{ prompt: "third", displayedInChat: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("consumes displayed guidance before ordinary queued guidance", () => {
|
||||||
|
const chat = useClaudeChatStore.getState();
|
||||||
|
const tabId = chat.activeTabId;
|
||||||
|
|
||||||
|
chat.clearQueuedGuidance(tabId);
|
||||||
|
chat.queueGuidance(tabId, "first");
|
||||||
|
chat.queueGuidance(tabId, "second");
|
||||||
|
|
||||||
|
const secondId = useClaudeChatStore
|
||||||
|
.getState()
|
||||||
|
.tabs.find((tab) => tab.id === tabId)?.queuedGuidance?.[1].id;
|
||||||
|
useClaudeChatStore.getState().displayQueuedGuidanceInChat(tabId, secondId);
|
||||||
|
|
||||||
|
const selected = chat.consumeQueuedGuidance(tabId);
|
||||||
|
expect(selected?.prompt).toBe("second");
|
||||||
|
expect(
|
||||||
|
useClaudeChatStore
|
||||||
|
.getState()
|
||||||
|
.tabs.find((tab) => tab.id === tabId)
|
||||||
|
?.queuedGuidance?.map((item) => item.prompt),
|
||||||
|
).toEqual(["first"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { useClaudeSetupStore } from "@/stores/claude-setup-store";
|
||||||
|
|
||||||
// advanceSteps is module-private — replicate for testing
|
// advanceSteps is module-private — replicate for testing
|
||||||
type StepStatus = "pending" | "active" | "complete" | "error";
|
type StepStatus = "pending" | "active" | "complete" | "error";
|
||||||
|
|
@ -9,7 +11,11 @@ interface StepInfo {
|
||||||
status: StepStatus;
|
status: StepStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
function advanceSteps(steps: StepInfo[], targetId: string, order: string[]): StepInfo[] {
|
function advanceSteps(
|
||||||
|
steps: StepInfo[],
|
||||||
|
targetId: string,
|
||||||
|
order: string[],
|
||||||
|
): StepInfo[] {
|
||||||
const targetIdx = order.indexOf(targetId);
|
const targetIdx = order.indexOf(targetId);
|
||||||
return steps.map((s) => {
|
return steps.map((s) => {
|
||||||
const thisIdx = order.indexOf(s.id);
|
const thisIdx = order.indexOf(s.id);
|
||||||
|
|
@ -91,3 +97,384 @@ describe("advanceSteps", () => {
|
||||||
expect(result[2].status).toBe("pending");
|
expect(result[2].status).toBe("pending");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("useClaudeSetupStore.saveApiKey", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useClaudeSetupStore.setState({
|
||||||
|
status: "ready",
|
||||||
|
isInstalling: false,
|
||||||
|
isLoggingIn: false,
|
||||||
|
isSavingApiKey: false,
|
||||||
|
isClearingApiKey: false,
|
||||||
|
error: null,
|
||||||
|
version: "1.0.0",
|
||||||
|
providerKind: "claude-code",
|
||||||
|
accountEmail: null,
|
||||||
|
providerModel: null,
|
||||||
|
providerBaseUrl: null,
|
||||||
|
openAiCredentials: [],
|
||||||
|
activeOpenAiCredentialId: null,
|
||||||
|
installSteps: [],
|
||||||
|
installLogs: [],
|
||||||
|
installLogsVisible: false,
|
||||||
|
loginSteps: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires Claude Code before saving provider credentials", async () => {
|
||||||
|
useClaudeSetupStore.setState({ status: "not-installed" });
|
||||||
|
|
||||||
|
const success = await useClaudeSetupStore
|
||||||
|
.getState()
|
||||||
|
.saveApiKey(
|
||||||
|
"sk-test",
|
||||||
|
"https://api.deepseek.com/anthropic",
|
||||||
|
"openai-compatible",
|
||||||
|
"deepseek-v4-pro",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(success).toBe(false);
|
||||||
|
expect(invoke).not.toHaveBeenCalled();
|
||||||
|
expect(useClaudeSetupStore.getState().error).toBe(
|
||||||
|
"Install Claude Code before configuring an AI provider.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies OpenAI-compatible credentials before saving them", async () => {
|
||||||
|
vi.mocked(invoke).mockImplementation(async (command) => {
|
||||||
|
if (command === "check_claude_status") {
|
||||||
|
return {
|
||||||
|
installed: true,
|
||||||
|
authenticated: true,
|
||||||
|
binary_path: null,
|
||||||
|
version: "OpenAI-compatible provider",
|
||||||
|
provider_kind: "openai-compatible",
|
||||||
|
account_email: null,
|
||||||
|
provider_model: "deepseek-v4-pro",
|
||||||
|
provider_base_url: "https://api.deepseek.com/anthropic",
|
||||||
|
missing_git: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (command === "list_openai_compatible_credentials") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "cred-1",
|
||||||
|
label: "DeepSeek",
|
||||||
|
model: "deepseek-v4-pro",
|
||||||
|
base_url: "https://api.deepseek.com/anthropic",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const success = await useClaudeSetupStore
|
||||||
|
.getState()
|
||||||
|
.saveApiKey(
|
||||||
|
"sk-test",
|
||||||
|
"https://api.deepseek.com/anthropic",
|
||||||
|
"openai-compatible",
|
||||||
|
"deepseek-v4-pro",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(success).toBe(true);
|
||||||
|
expect(invoke).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"verify_openai_compatible_api_key",
|
||||||
|
{
|
||||||
|
apiKey: "sk-test",
|
||||||
|
baseUrl: "https://api.deepseek.com/anthropic",
|
||||||
|
model: "deepseek-v4-pro",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", {
|
||||||
|
apiKey: "sk-test",
|
||||||
|
baseUrl: "https://api.deepseek.com/anthropic",
|
||||||
|
provider: "openai-compatible",
|
||||||
|
model: "deepseek-v4-pro",
|
||||||
|
credentialLabel: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes legacy DeepSeek root URLs to the native Anthropic endpoint", async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
|
||||||
|
const success = await useClaudeSetupStore
|
||||||
|
.getState()
|
||||||
|
.saveApiKey(
|
||||||
|
"sk-test",
|
||||||
|
"https://api.deepseek.com",
|
||||||
|
"openai-compatible",
|
||||||
|
"deepseek-v4-pro",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(success).toBe(true);
|
||||||
|
expect(invoke).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"verify_openai_compatible_api_key",
|
||||||
|
{
|
||||||
|
apiKey: "sk-test",
|
||||||
|
baseUrl: "https://api.deepseek.com/anthropic",
|
||||||
|
model: "deepseek-v4-pro",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", {
|
||||||
|
apiKey: "sk-test",
|
||||||
|
baseUrl: "https://api.deepseek.com/anthropic",
|
||||||
|
provider: "openai-compatible",
|
||||||
|
model: "deepseek-v4-pro",
|
||||||
|
credentialLabel: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes legacy Qwen compatible URLs to the native Anthropic endpoint", async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
|
||||||
|
const success = await useClaudeSetupStore
|
||||||
|
.getState()
|
||||||
|
.saveApiKey(
|
||||||
|
"sk-test",
|
||||||
|
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
"openai-compatible",
|
||||||
|
"qwen3-max-2026-01-23",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(success).toBe(true);
|
||||||
|
expect(invoke).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"verify_openai_compatible_api_key",
|
||||||
|
{
|
||||||
|
apiKey: "sk-test",
|
||||||
|
baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic",
|
||||||
|
model: "qwen3-max-2026-01-23",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", {
|
||||||
|
apiKey: "sk-test",
|
||||||
|
baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic",
|
||||||
|
provider: "openai-compatible",
|
||||||
|
model: "qwen3-max-2026-01-23",
|
||||||
|
credentialLabel: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves Qwen native Anthropic URLs when saving credentials", async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
|
||||||
|
const success = await useClaudeSetupStore
|
||||||
|
.getState()
|
||||||
|
.saveApiKey(
|
||||||
|
"sk-test",
|
||||||
|
"https://dashscope.aliyuncs.com/apps/anthropic/v1",
|
||||||
|
"openai-compatible",
|
||||||
|
"qwen3-max-2026-01-23",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(success).toBe(true);
|
||||||
|
expect(invoke).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"verify_openai_compatible_api_key",
|
||||||
|
{
|
||||||
|
apiKey: "sk-test",
|
||||||
|
baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic",
|
||||||
|
model: "qwen3-max-2026-01-23",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", {
|
||||||
|
apiKey: "sk-test",
|
||||||
|
baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic",
|
||||||
|
provider: "openai-compatible",
|
||||||
|
model: "qwen3-max-2026-01-23",
|
||||||
|
credentialLabel: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes Moonshot compatible URLs to the native Anthropic endpoint", async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
|
||||||
|
const success = await useClaudeSetupStore
|
||||||
|
.getState()
|
||||||
|
.saveApiKey(
|
||||||
|
"sk-test",
|
||||||
|
"https://api.moonshot.cn/v1",
|
||||||
|
"openai-compatible",
|
||||||
|
"kimi-k2.5",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(success).toBe(true);
|
||||||
|
expect(invoke).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"verify_openai_compatible_api_key",
|
||||||
|
{
|
||||||
|
apiKey: "sk-test",
|
||||||
|
baseUrl: "https://api.moonshot.ai/anthropic",
|
||||||
|
model: "kimi-k2.5",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", {
|
||||||
|
apiKey: "sk-test",
|
||||||
|
baseUrl: "https://api.moonshot.ai/anthropic",
|
||||||
|
provider: "openai-compatible",
|
||||||
|
model: "kimi-k2.5",
|
||||||
|
credentialLabel: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves Moonshot Anthropic-looking URLs when saving credentials", async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
|
||||||
|
const success = await useClaudeSetupStore
|
||||||
|
.getState()
|
||||||
|
.saveApiKey(
|
||||||
|
"sk-test",
|
||||||
|
"https://api.moonshot.ai/anthropic/v1",
|
||||||
|
"openai-compatible",
|
||||||
|
"kimi-k2.5",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(success).toBe(true);
|
||||||
|
expect(invoke).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"verify_openai_compatible_api_key",
|
||||||
|
{
|
||||||
|
apiKey: "sk-test",
|
||||||
|
baseUrl: "https://api.moonshot.ai/anthropic",
|
||||||
|
model: "kimi-k2.5",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", {
|
||||||
|
apiKey: "sk-test",
|
||||||
|
baseUrl: "https://api.moonshot.ai/anthropic",
|
||||||
|
provider: "openai-compatible",
|
||||||
|
model: "kimi-k2.5",
|
||||||
|
credentialLabel: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows local OpenAI-compatible providers without an API key", async () => {
|
||||||
|
vi.mocked(invoke).mockImplementation(async (command) => {
|
||||||
|
if (command === "check_claude_status") {
|
||||||
|
return {
|
||||||
|
installed: true,
|
||||||
|
authenticated: true,
|
||||||
|
binary_path: null,
|
||||||
|
version: "OpenAI-compatible provider",
|
||||||
|
provider_kind: "openai-compatible",
|
||||||
|
account_email: null,
|
||||||
|
provider_model: "llama3.2",
|
||||||
|
provider_base_url: "http://localhost:11434/v1",
|
||||||
|
missing_git: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (command === "list_openai_compatible_credentials") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "ollama-cred",
|
||||||
|
label: "Ollama",
|
||||||
|
model: "llama3.2",
|
||||||
|
base_url: "http://localhost:11434/v1",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const success = await useClaudeSetupStore
|
||||||
|
.getState()
|
||||||
|
.saveApiKey(
|
||||||
|
"",
|
||||||
|
"http://localhost:11434/v1",
|
||||||
|
"openai-compatible",
|
||||||
|
"llama3.2",
|
||||||
|
"Ollama",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(success).toBe(true);
|
||||||
|
expect(invoke).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"verify_openai_compatible_api_key",
|
||||||
|
{
|
||||||
|
apiKey: "",
|
||||||
|
baseUrl: "http://localhost:11434/v1",
|
||||||
|
model: "llama3.2",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", {
|
||||||
|
apiKey: "",
|
||||||
|
baseUrl: "http://localhost:11434/v1",
|
||||||
|
provider: "openai-compatible",
|
||||||
|
model: "llama3.2",
|
||||||
|
credentialLabel: "Ollama",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not save OpenAI-compatible credentials when verification fails", async () => {
|
||||||
|
vi.mocked(invoke).mockRejectedValueOnce(
|
||||||
|
new Error("Invalid provider API key"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const success = await useClaudeSetupStore
|
||||||
|
.getState()
|
||||||
|
.saveApiKey(
|
||||||
|
"sk-test",
|
||||||
|
"https://api.deepseek.com/anthropic",
|
||||||
|
"openai-compatible",
|
||||||
|
"deepseek-v4-pro",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(success).toBe(false);
|
||||||
|
expect(invoke).toHaveBeenCalledTimes(1);
|
||||||
|
expect(invoke).toHaveBeenCalledWith("verify_openai_compatible_api_key", {
|
||||||
|
apiKey: "sk-test",
|
||||||
|
baseUrl: "https://api.deepseek.com/anthropic",
|
||||||
|
model: "deepseek-v4-pro",
|
||||||
|
});
|
||||||
|
expect(useClaudeSetupStore.getState().error).toBe(
|
||||||
|
"Invalid provider API key",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears saved credentials and refreshes status", async () => {
|
||||||
|
useClaudeSetupStore.setState({
|
||||||
|
status: "ready",
|
||||||
|
version: "OpenAI-compatible provider",
|
||||||
|
providerKind: "openai-compatible",
|
||||||
|
providerModel: "qwen3-coder-plus",
|
||||||
|
providerBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
});
|
||||||
|
vi.mocked(invoke).mockImplementation(async (command) => {
|
||||||
|
if (command === "check_claude_status") {
|
||||||
|
return {
|
||||||
|
installed: true,
|
||||||
|
authenticated: false,
|
||||||
|
binary_path: null,
|
||||||
|
version: "1.0.0",
|
||||||
|
provider_kind: "claude-code",
|
||||||
|
account_email: null,
|
||||||
|
provider_model: null,
|
||||||
|
provider_base_url: null,
|
||||||
|
missing_git: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (command === "list_openai_compatible_credentials") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const success = await useClaudeSetupStore.getState().clearApiKey();
|
||||||
|
|
||||||
|
expect(success).toBe(true);
|
||||||
|
expect(invoke).toHaveBeenNthCalledWith(1, "clear_anthropic_api_key");
|
||||||
|
expect(invoke).toHaveBeenNthCalledWith(2, "check_claude_status");
|
||||||
|
expect(invoke).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
"list_openai_compatible_credentials",
|
||||||
|
);
|
||||||
|
expect(useClaudeSetupStore.getState().status).toBe("not-authenticated");
|
||||||
|
expect(useClaudeSetupStore.getState().providerModel).toBeNull();
|
||||||
|
expect(useClaudeSetupStore.getState().providerBaseUrl).toBeNull();
|
||||||
|
expect(useClaudeSetupStore.getState().isClearingApiKey).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,20 @@
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { useDocumentStore, getCurrentPdfBytes, clearPdfBytesCache, type ProjectFile } from "@/stores/document-store";
|
import {
|
||||||
|
readDir,
|
||||||
|
readTextFile,
|
||||||
|
rename,
|
||||||
|
stat,
|
||||||
|
writeTextFile,
|
||||||
|
} from "@tauri-apps/plugin-fs";
|
||||||
|
import {
|
||||||
|
useDocumentStore,
|
||||||
|
getCurrentPdfBytes,
|
||||||
|
getCurrentPdfRootId,
|
||||||
|
clearPdfBytesCache,
|
||||||
|
type ProjectFile,
|
||||||
|
} from "@/stores/document-store";
|
||||||
|
import { useProjectStore } from "@/stores/project-store";
|
||||||
|
|
||||||
// Mock history store
|
// Mock history store
|
||||||
vi.mock("@/stores/history-store", () => ({
|
vi.mock("@/stores/history-store", () => ({
|
||||||
|
|
@ -9,6 +23,7 @@ vi.mock("@/stores/history-store", () => ({
|
||||||
init: vi.fn(() => Promise.resolve()),
|
init: vi.fn(() => Promise.resolve()),
|
||||||
loadSnapshots: vi.fn(() => Promise.resolve()),
|
loadSnapshots: vi.fn(() => Promise.resolve()),
|
||||||
createSnapshot: vi.fn(() => Promise.resolve()),
|
createSnapshot: vi.fn(() => Promise.resolve()),
|
||||||
|
reset: vi.fn(),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
@ -37,6 +52,7 @@ function makeFile(overrides: Partial<ProjectFile> = {}): ProjectFile {
|
||||||
|
|
||||||
describe("useDocumentStore", () => {
|
describe("useDocumentStore", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
clearPdfBytesCache();
|
clearPdfBytesCache();
|
||||||
useDocumentStore.setState({
|
useDocumentStore.setState({
|
||||||
projectRoot: "/project",
|
projectRoot: "/project",
|
||||||
|
|
@ -54,6 +70,10 @@ describe("useDocumentStore", () => {
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
initialized: true,
|
initialized: true,
|
||||||
});
|
});
|
||||||
|
useProjectStore.setState({
|
||||||
|
recentProjects: [],
|
||||||
|
lastProjectFolder: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getActiveFile logic", () => {
|
describe("getActiveFile logic", () => {
|
||||||
|
|
@ -61,7 +81,9 @@ describe("useDocumentStore", () => {
|
||||||
// Validates that getActiveFile() correctly resolves the active file
|
// Validates that getActiveFile() correctly resolves the active file
|
||||||
// by confirming insertAtCursor modifies the right file's content
|
// by confirming insertAtCursor modifies the right file's content
|
||||||
useDocumentStore.getState().insertAtCursor("!");
|
useDocumentStore.getState().insertAtCursor("!");
|
||||||
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
|
const file = useDocumentStore
|
||||||
|
.getState()
|
||||||
|
.files.find((f) => f.id === "main.tex")!;
|
||||||
expect(file.content).toBe("Hello! World");
|
expect(file.content).toBe("Hello! World");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -69,11 +91,149 @@ describe("useDocumentStore", () => {
|
||||||
useDocumentStore.setState({ activeFileId: "nonexistent" });
|
useDocumentStore.setState({ activeFileId: "nonexistent" });
|
||||||
useDocumentStore.getState().insertAtCursor("text");
|
useDocumentStore.getState().insertAtCursor("text");
|
||||||
// Files should not be modified
|
// Files should not be modified
|
||||||
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
|
const file = useDocumentStore
|
||||||
|
.getState()
|
||||||
|
.files.find((f) => f.id === "main.tex")!;
|
||||||
expect(file.content).toBe("Hello World");
|
expect(file.content).toBe("Hello World");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("openProject", () => {
|
||||||
|
it("re-authorizes the project directory before scanning", async () => {
|
||||||
|
const projectPath = "E:\\overleaf-cache\\论文项目";
|
||||||
|
let resolveAuthorization!: () => void;
|
||||||
|
const authorizationPromise = new Promise<void>((resolve) => {
|
||||||
|
resolveAuthorization = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(invoke).mockReturnValue(
|
||||||
|
authorizationPromise as ReturnType<typeof invoke>,
|
||||||
|
);
|
||||||
|
vi.mocked(readDir).mockResolvedValue([
|
||||||
|
{ name: "main.tex", isDirectory: false },
|
||||||
|
] as any);
|
||||||
|
vi.mocked(readTextFile).mockResolvedValue("\\documentclass{article}");
|
||||||
|
|
||||||
|
const openProjectPromise = useDocumentStore
|
||||||
|
.getState()
|
||||||
|
.openProject(projectPath);
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("allow_project_directory", {
|
||||||
|
rootPath: projectPath,
|
||||||
|
});
|
||||||
|
expect(readDir).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
resolveAuthorization();
|
||||||
|
await openProjectPromise;
|
||||||
|
|
||||||
|
expect(readDir).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips Python cache directories and bytecode files during open", async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValue(undefined as never);
|
||||||
|
vi.mocked(readDir).mockImplementation(async (dir: string | URL) => {
|
||||||
|
const dirPath = String(dir);
|
||||||
|
if (dirPath === "/project") {
|
||||||
|
return [
|
||||||
|
{ name: "__pycache__", isDirectory: true },
|
||||||
|
{ name: "main.tex", isDirectory: false },
|
||||||
|
{ name: "tool.py", isDirectory: false },
|
||||||
|
{ name: "compiled.pyc", isDirectory: false },
|
||||||
|
] as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected readDir path: ${dirPath}`);
|
||||||
|
});
|
||||||
|
vi.mocked(stat).mockResolvedValue({ size: 32 } as any);
|
||||||
|
vi.mocked(readTextFile).mockImplementation(async (path: string | URL) => {
|
||||||
|
const filePath = String(path);
|
||||||
|
if (filePath === "/project/main.tex") {
|
||||||
|
return "\\documentclass{article}";
|
||||||
|
}
|
||||||
|
if (filePath === "/project/tool.py") {
|
||||||
|
return "print('hello')";
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected readTextFile path: ${filePath}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await useDocumentStore.getState().openProject("/project");
|
||||||
|
|
||||||
|
expect(readDir).toHaveBeenCalledWith("/project");
|
||||||
|
expect(readDir).not.toHaveBeenCalledWith("/project/__pycache__");
|
||||||
|
expect(stat).toHaveBeenCalledTimes(1);
|
||||||
|
expect(stat).toHaveBeenCalledWith("/project/tool.py");
|
||||||
|
expect(readTextFile).toHaveBeenCalledTimes(2);
|
||||||
|
expect(readTextFile).not.toHaveBeenCalledWith("/project/compiled.pyc");
|
||||||
|
expect(
|
||||||
|
useDocumentStore.getState().files.map((file) => file.relativePath),
|
||||||
|
).toEqual(["main.tex", "tool.py"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renameProject", () => {
|
||||||
|
it("renames the project folder and reopens the new path", async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValue(undefined as never);
|
||||||
|
vi.mocked(readDir).mockResolvedValue([
|
||||||
|
{ name: "main.tex", isDirectory: false },
|
||||||
|
] as any);
|
||||||
|
vi.mocked(readTextFile).mockResolvedValue("\\documentclass{article}");
|
||||||
|
useProjectStore.setState({
|
||||||
|
recentProjects: [{ path: "/work/old", name: "old", lastOpened: 1 }],
|
||||||
|
lastProjectFolder: "/work",
|
||||||
|
});
|
||||||
|
useDocumentStore.setState({
|
||||||
|
projectRoot: "/work/old",
|
||||||
|
files: [makeFile({ absolutePath: "/work/old/main.tex" })],
|
||||||
|
});
|
||||||
|
|
||||||
|
await useDocumentStore.getState().renameProject("renamed");
|
||||||
|
|
||||||
|
expect(rename).toHaveBeenCalledWith("/work/old", "/work/renamed");
|
||||||
|
expect(invoke).toHaveBeenCalledWith("migrate_project_sessions", {
|
||||||
|
oldProjectPath: "/work/old",
|
||||||
|
newProjectPath: "/work/renamed",
|
||||||
|
});
|
||||||
|
expect(invoke).toHaveBeenCalledWith("allow_project_directory", {
|
||||||
|
rootPath: "/work/renamed",
|
||||||
|
});
|
||||||
|
expect(useDocumentStore.getState().projectRoot).toBe("/work/renamed");
|
||||||
|
expect(useProjectStore.getState().recentProjects[0]).toMatchObject({
|
||||||
|
path: "/work/renamed",
|
||||||
|
name: "renamed",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
useProjectStore
|
||||||
|
.getState()
|
||||||
|
.recentProjects.some((project) => project.path === "/work/old"),
|
||||||
|
).toBe(false);
|
||||||
|
expect(useProjectStore.getState().lastProjectFolder).toBe("/work");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves dirty files before renaming the project folder", async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValue(undefined as never);
|
||||||
|
vi.mocked(writeTextFile).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(readDir).mockResolvedValue([
|
||||||
|
{ name: "main.tex", isDirectory: false },
|
||||||
|
] as any);
|
||||||
|
vi.mocked(readTextFile).mockResolvedValue("\\documentclass{article}");
|
||||||
|
useDocumentStore.setState({
|
||||||
|
projectRoot: "/work/old",
|
||||||
|
files: [
|
||||||
|
makeFile({
|
||||||
|
absolutePath: "/work/old/main.tex",
|
||||||
|
content: "dirty",
|
||||||
|
isDirty: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await useDocumentStore.getState().renameProject("renamed");
|
||||||
|
|
||||||
|
expect(writeTextFile).toHaveBeenCalledWith("/work/old/main.tex", "dirty");
|
||||||
|
expect(rename).toHaveBeenCalledWith("/work/old", "/work/renamed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("insertAtCursor", () => {
|
describe("insertAtCursor", () => {
|
||||||
it("inserts text at cursor position", () => {
|
it("inserts text at cursor position", () => {
|
||||||
useDocumentStore.getState().insertAtCursor(", Beautiful");
|
useDocumentStore.getState().insertAtCursor(", Beautiful");
|
||||||
|
|
@ -91,14 +251,18 @@ describe("useDocumentStore", () => {
|
||||||
it("inserts at beginning when cursor is at 0", () => {
|
it("inserts at beginning when cursor is at 0", () => {
|
||||||
useDocumentStore.setState({ cursorPosition: 0 });
|
useDocumentStore.setState({ cursorPosition: 0 });
|
||||||
useDocumentStore.getState().insertAtCursor(">> ");
|
useDocumentStore.getState().insertAtCursor(">> ");
|
||||||
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
|
const file = useDocumentStore
|
||||||
|
.getState()
|
||||||
|
.files.find((f) => f.id === "main.tex")!;
|
||||||
expect(file.content).toBe(">> Hello World");
|
expect(file.content).toBe(">> Hello World");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("inserts at end when cursor is at content length", () => {
|
it("inserts at end when cursor is at content length", () => {
|
||||||
useDocumentStore.setState({ cursorPosition: 11 }); // "Hello World".length
|
useDocumentStore.setState({ cursorPosition: 11 }); // "Hello World".length
|
||||||
useDocumentStore.getState().insertAtCursor("!");
|
useDocumentStore.getState().insertAtCursor("!");
|
||||||
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
|
const file = useDocumentStore
|
||||||
|
.getState()
|
||||||
|
.files.find((f) => f.id === "main.tex")!;
|
||||||
expect(file.content).toBe("Hello World!");
|
expect(file.content).toBe("Hello World!");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -126,7 +290,9 @@ describe("useDocumentStore", () => {
|
||||||
it("replaces a range of text", () => {
|
it("replaces a range of text", () => {
|
||||||
// Replace "World" (indices 6-11) with "Universe"
|
// Replace "World" (indices 6-11) with "Universe"
|
||||||
useDocumentStore.getState().replaceSelection(6, 11, "Universe");
|
useDocumentStore.getState().replaceSelection(6, 11, "Universe");
|
||||||
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
|
const file = useDocumentStore
|
||||||
|
.getState()
|
||||||
|
.files.find((f) => f.id === "main.tex")!;
|
||||||
expect(file.content).toBe("Hello Universe");
|
expect(file.content).toBe("Hello Universe");
|
||||||
expect(file.isDirty).toBe(true);
|
expect(file.isDirty).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
@ -138,13 +304,17 @@ describe("useDocumentStore", () => {
|
||||||
|
|
||||||
it("can delete text (empty replacement)", () => {
|
it("can delete text (empty replacement)", () => {
|
||||||
useDocumentStore.getState().replaceSelection(5, 11, "");
|
useDocumentStore.getState().replaceSelection(5, 11, "");
|
||||||
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
|
const file = useDocumentStore
|
||||||
|
.getState()
|
||||||
|
.files.find((f) => f.id === "main.tex")!;
|
||||||
expect(file.content).toBe("Hello");
|
expect(file.content).toBe("Hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can insert at a point (start === end)", () => {
|
it("can insert at a point (start === end)", () => {
|
||||||
useDocumentStore.getState().replaceSelection(5, 5, " Beautiful");
|
useDocumentStore.getState().replaceSelection(5, 5, " Beautiful");
|
||||||
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
|
const file = useDocumentStore
|
||||||
|
.getState()
|
||||||
|
.files.find((f) => f.id === "main.tex")!;
|
||||||
expect(file.content).toBe("Hello Beautiful World");
|
expect(file.content).toBe("Hello Beautiful World");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -159,9 +329,13 @@ describe("useDocumentStore", () => {
|
||||||
|
|
||||||
describe("findAndReplace", () => {
|
describe("findAndReplace", () => {
|
||||||
it("replaces first occurrence", () => {
|
it("replaces first occurrence", () => {
|
||||||
const result = useDocumentStore.getState().findAndReplace("World", "Universe");
|
const result = useDocumentStore
|
||||||
|
.getState()
|
||||||
|
.findAndReplace("World", "Universe");
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
|
const file = useDocumentStore
|
||||||
|
.getState()
|
||||||
|
.files.find((f) => f.id === "main.tex")!;
|
||||||
expect(file.content).toBe("Hello Universe");
|
expect(file.content).toBe("Hello Universe");
|
||||||
expect(file.isDirty).toBe(true);
|
expect(file.isDirty).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
@ -169,7 +343,9 @@ describe("useDocumentStore", () => {
|
||||||
it("returns false when find string is not found", () => {
|
it("returns false when find string is not found", () => {
|
||||||
const result = useDocumentStore.getState().findAndReplace("xyz", "abc");
|
const result = useDocumentStore.getState().findAndReplace("xyz", "abc");
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
|
const file = useDocumentStore
|
||||||
|
.getState()
|
||||||
|
.files.find((f) => f.id === "main.tex")!;
|
||||||
expect(file.isDirty).toBe(false); // not modified
|
expect(file.isDirty).toBe(false); // not modified
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -186,9 +362,13 @@ describe("useDocumentStore", () => {
|
||||||
useDocumentStore.setState({
|
useDocumentStore.setState({
|
||||||
files: [makeFile({ content: "price is $10.00" })],
|
files: [makeFile({ content: "price is $10.00" })],
|
||||||
});
|
});
|
||||||
const result = useDocumentStore.getState().findAndReplace("$10.00", "€12.00");
|
const result = useDocumentStore
|
||||||
|
.getState()
|
||||||
|
.findAndReplace("$10.00", "€12.00");
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(useDocumentStore.getState().files[0].content).toBe("price is €12.00");
|
expect(useDocumentStore.getState().files[0].content).toBe(
|
||||||
|
"price is €12.00",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does nothing for image files", () => {
|
it("does nothing for image files", () => {
|
||||||
|
|
@ -203,7 +383,9 @@ describe("useDocumentStore", () => {
|
||||||
describe("updateFileContent", () => {
|
describe("updateFileContent", () => {
|
||||||
it("updates content and marks dirty", () => {
|
it("updates content and marks dirty", () => {
|
||||||
useDocumentStore.getState().updateFileContent("main.tex", "New content");
|
useDocumentStore.getState().updateFileContent("main.tex", "New content");
|
||||||
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
|
const file = useDocumentStore
|
||||||
|
.getState()
|
||||||
|
.files.find((f) => f.id === "main.tex")!;
|
||||||
expect(file.content).toBe("New content");
|
expect(file.content).toBe("New content");
|
||||||
expect(file.isDirty).toBe(true);
|
expect(file.isDirty).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
@ -212,11 +394,18 @@ describe("useDocumentStore", () => {
|
||||||
useDocumentStore.setState({
|
useDocumentStore.setState({
|
||||||
files: [
|
files: [
|
||||||
makeFile(),
|
makeFile(),
|
||||||
makeFile({ id: "other.tex", name: "other.tex", relativePath: "other.tex", content: "Other" }),
|
makeFile({
|
||||||
|
id: "other.tex",
|
||||||
|
name: "other.tex",
|
||||||
|
relativePath: "other.tex",
|
||||||
|
content: "Other",
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
useDocumentStore.getState().updateFileContent("main.tex", "Changed");
|
useDocumentStore.getState().updateFileContent("main.tex", "Changed");
|
||||||
const other = useDocumentStore.getState().files.find((f) => f.id === "other.tex")!;
|
const other = useDocumentStore
|
||||||
|
.getState()
|
||||||
|
.files.find((f) => f.id === "other.tex")!;
|
||||||
expect(other.content).toBe("Other");
|
expect(other.content).toBe("Other");
|
||||||
expect(other.isDirty).toBe(false);
|
expect(other.isDirty).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
@ -255,6 +444,39 @@ describe("useDocumentStore", () => {
|
||||||
expect(state.cursorPosition).toBe(100);
|
expect(state.cursorPosition).toBe(100);
|
||||||
expect(state.selectionRange).toBeNull();
|
expect(state.selectionRange).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the current PDF when switching to a non-tex file", () => {
|
||||||
|
const pdfBytes = new Uint8Array([1, 2, 3]);
|
||||||
|
useDocumentStore.setState({
|
||||||
|
files: [
|
||||||
|
makeFile({
|
||||||
|
content:
|
||||||
|
"\\documentclass{article}\\begin{document}Hi\\end{document}",
|
||||||
|
}),
|
||||||
|
makeFile({
|
||||||
|
id: "analysis.py",
|
||||||
|
name: "analysis.py",
|
||||||
|
relativePath: "analysis.py",
|
||||||
|
absolutePath: "/project/analysis.py",
|
||||||
|
type: "other",
|
||||||
|
content: "print('hello')",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
activeFileId: "main.tex",
|
||||||
|
selectionRange: { start: 0, end: 3 },
|
||||||
|
});
|
||||||
|
useDocumentStore.getState().setPdfData(pdfBytes, "main.tex");
|
||||||
|
const revisionBefore = useDocumentStore.getState().pdfRevision;
|
||||||
|
|
||||||
|
useDocumentStore.getState().setActiveFile("analysis.py");
|
||||||
|
|
||||||
|
const state = useDocumentStore.getState();
|
||||||
|
expect(state.activeFileId).toBe("analysis.py");
|
||||||
|
expect(state.selectionRange).toBeNull();
|
||||||
|
expect(state.pdfRevision).toBe(revisionBefore);
|
||||||
|
expect(getCurrentPdfRootId()).toBe("main.tex");
|
||||||
|
expect(getCurrentPdfBytes()).toEqual(pdfBytes);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("saveFile", () => {
|
describe("saveFile", () => {
|
||||||
|
|
@ -268,7 +490,10 @@ describe("useDocumentStore", () => {
|
||||||
files: [makeFile({ isDirty: true, content: "saved content" })],
|
files: [makeFile({ isDirty: true, content: "saved content" })],
|
||||||
});
|
});
|
||||||
await useDocumentStore.getState().saveFile("main.tex");
|
await useDocumentStore.getState().saveFile("main.tex");
|
||||||
expect(writeTextFile).toHaveBeenCalledWith("/project/main.tex", "saved content");
|
expect(writeTextFile).toHaveBeenCalledWith(
|
||||||
|
"/project/main.tex",
|
||||||
|
"saved content",
|
||||||
|
);
|
||||||
expect(useDocumentStore.getState().files[0].isDirty).toBe(false);
|
expect(useDocumentStore.getState().files[0].isDirty).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -283,7 +508,9 @@ describe("useDocumentStore", () => {
|
||||||
|
|
||||||
it("skips saving when content is null", async () => {
|
it("skips saving when content is null", async () => {
|
||||||
useDocumentStore.setState({
|
useDocumentStore.setState({
|
||||||
files: [makeFile({ isDirty: true, content: null as unknown as string })],
|
files: [
|
||||||
|
makeFile({ isDirty: true, content: null as unknown as string }),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
await useDocumentStore.getState().saveFile("main.tex");
|
await useDocumentStore.getState().saveFile("main.tex");
|
||||||
expect(writeTextFile).not.toHaveBeenCalled();
|
expect(writeTextFile).not.toHaveBeenCalled();
|
||||||
|
|
@ -308,19 +535,36 @@ describe("useDocumentStore", () => {
|
||||||
useDocumentStore.setState({
|
useDocumentStore.setState({
|
||||||
files: [
|
files: [
|
||||||
makeFile({ isDirty: true, content: "dirty content" }),
|
makeFile({ isDirty: true, content: "dirty content" }),
|
||||||
makeFile({ id: "clean.tex", name: "clean.tex", absolutePath: "/project/clean.tex", relativePath: "clean.tex", isDirty: false, content: "clean" }),
|
makeFile({
|
||||||
|
id: "clean.tex",
|
||||||
|
name: "clean.tex",
|
||||||
|
absolutePath: "/project/clean.tex",
|
||||||
|
relativePath: "clean.tex",
|
||||||
|
isDirty: false,
|
||||||
|
content: "clean",
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
await useDocumentStore.getState().saveAllFiles();
|
await useDocumentStore.getState().saveAllFiles();
|
||||||
expect(writeTextFile).toHaveBeenCalledTimes(1);
|
expect(writeTextFile).toHaveBeenCalledTimes(1);
|
||||||
expect(writeTextFile).toHaveBeenCalledWith("/project/main.tex", "dirty content");
|
expect(writeTextFile).toHaveBeenCalledWith(
|
||||||
|
"/project/main.tex",
|
||||||
|
"dirty content",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("saves dirty files with empty string content (regression: empty string is not falsy-skipped)", async () => {
|
it("saves dirty files with empty string content (regression: empty string is not falsy-skipped)", async () => {
|
||||||
useDocumentStore.setState({
|
useDocumentStore.setState({
|
||||||
files: [
|
files: [
|
||||||
makeFile({ isDirty: true, content: "" }),
|
makeFile({ isDirty: true, content: "" }),
|
||||||
makeFile({ id: "slide.tex", name: "slide.tex", absolutePath: "/project/slide.tex", relativePath: "slide.tex", isDirty: true, content: "" }),
|
makeFile({
|
||||||
|
id: "slide.tex",
|
||||||
|
name: "slide.tex",
|
||||||
|
absolutePath: "/project/slide.tex",
|
||||||
|
relativePath: "slide.tex",
|
||||||
|
isDirty: true,
|
||||||
|
content: "",
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
await useDocumentStore.getState().saveAllFiles();
|
await useDocumentStore.getState().saveAllFiles();
|
||||||
|
|
@ -353,9 +597,11 @@ describe("useDocumentStore", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("setCompileError stores the error string (regression: Tauri string errors must be preserved)", () => {
|
it("setCompileError stores the error string (regression: Tauri string errors must be preserved)", () => {
|
||||||
useDocumentStore.getState().setCompileError("Compilation failed\n\n! Undefined control sequence.");
|
useDocumentStore
|
||||||
|
.getState()
|
||||||
|
.setCompileError("Compilation failed\n\n! Undefined control sequence.");
|
||||||
expect(useDocumentStore.getState().compileError).toBe(
|
expect(useDocumentStore.getState().compileError).toBe(
|
||||||
"Compilation failed\n\n! Undefined control sequence."
|
"Compilation failed\n\n! Undefined control sequence.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,29 @@ function resetStores() {
|
||||||
messages: [],
|
messages: [],
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
|
streamingStartedAt: null,
|
||||||
error: null,
|
error: null,
|
||||||
totalInputTokens: 0,
|
totalInputTokens: 0,
|
||||||
totalOutputTokens: 0,
|
totalOutputTokens: 0,
|
||||||
tabs: [{ id: "tab-default", title: "New Chat", sessionId: null, messages: [], isStreaming: false, error: null, totalInputTokens: 0, totalOutputTokens: 0, draft: { input: "", pinnedContexts: [] } }],
|
tabs: [
|
||||||
|
{
|
||||||
|
id: "tab-default",
|
||||||
|
title: "New Chat",
|
||||||
|
projectPath: "/project",
|
||||||
|
sessionId: null,
|
||||||
|
providerKey: null,
|
||||||
|
sessionProviderKey: null,
|
||||||
|
messages: [],
|
||||||
|
isStreaming: false,
|
||||||
|
streamingStartedAt: null,
|
||||||
|
error: null,
|
||||||
|
totalInputTokens: 0,
|
||||||
|
totalOutputTokens: 0,
|
||||||
|
draft: { input: "", pinnedContexts: [] },
|
||||||
|
},
|
||||||
|
],
|
||||||
activeTabId: "tab-default",
|
activeTabId: "tab-default",
|
||||||
|
activeProjectPath: "/project",
|
||||||
_cancelledByUser: false,
|
_cancelledByUser: false,
|
||||||
});
|
});
|
||||||
useProposedChangesStore.setState({ changes: [] });
|
useProposedChangesStore.setState({ changes: [] });
|
||||||
|
|
@ -72,7 +90,9 @@ describe("Multi-tab merge triggers", () => {
|
||||||
chat.setActiveTab(tabB);
|
chat.setActiveTab(tabB);
|
||||||
|
|
||||||
// Proposed change should still be visible — it's file-scoped, not tab-scoped
|
// Proposed change should still be visible — it's file-scoped, not tab-scoped
|
||||||
const change = useProposedChangesStore.getState().getChangeForFile("main.tex");
|
const change = useProposedChangesStore
|
||||||
|
.getState()
|
||||||
|
.getChangeForFile("main.tex");
|
||||||
expect(change).toBeDefined();
|
expect(change).toBeDefined();
|
||||||
expect(change!.id).toBe("tool-from-tab-a");
|
expect(change!.id).toBe("tool-from-tab-a");
|
||||||
expect(change!.newContent).toBe("edited by tab A");
|
expect(change!.newContent).toBe("edited by tab A");
|
||||||
|
|
@ -162,9 +182,30 @@ describe("Multi-tab merge triggers", () => {
|
||||||
it("three sequential edits to the same file all preserve the original baseline", () => {
|
it("three sequential edits to the same file all preserve the original baseline", () => {
|
||||||
const store = useProposedChangesStore.getState();
|
const store = useProposedChangesStore.getState();
|
||||||
|
|
||||||
store.addChange({ id: "edit-1", filePath: "doc.tex", absolutePath: "/project/doc.tex", oldContent: "baseline", newContent: "v1", toolName: "Edit" });
|
store.addChange({
|
||||||
store.addChange({ id: "edit-2", filePath: "doc.tex", absolutePath: "/project/doc.tex", oldContent: "v1", newContent: "v2", toolName: "Edit" });
|
id: "edit-1",
|
||||||
store.addChange({ id: "edit-3", filePath: "doc.tex", absolutePath: "/project/doc.tex", oldContent: "v2", newContent: "v3", toolName: "MultiEdit" });
|
filePath: "doc.tex",
|
||||||
|
absolutePath: "/project/doc.tex",
|
||||||
|
oldContent: "baseline",
|
||||||
|
newContent: "v1",
|
||||||
|
toolName: "Edit",
|
||||||
|
});
|
||||||
|
store.addChange({
|
||||||
|
id: "edit-2",
|
||||||
|
filePath: "doc.tex",
|
||||||
|
absolutePath: "/project/doc.tex",
|
||||||
|
oldContent: "v1",
|
||||||
|
newContent: "v2",
|
||||||
|
toolName: "Edit",
|
||||||
|
});
|
||||||
|
store.addChange({
|
||||||
|
id: "edit-3",
|
||||||
|
filePath: "doc.tex",
|
||||||
|
absolutePath: "/project/doc.tex",
|
||||||
|
oldContent: "v2",
|
||||||
|
newContent: "v3",
|
||||||
|
toolName: "MultiEdit",
|
||||||
|
});
|
||||||
|
|
||||||
const { changes } = useProposedChangesStore.getState();
|
const { changes } = useProposedChangesStore.getState();
|
||||||
expect(changes).toHaveLength(1);
|
expect(changes).toHaveLength(1);
|
||||||
|
|
@ -182,13 +223,17 @@ describe("Multi-tab merge triggers", () => {
|
||||||
|
|
||||||
// Tab A starts streaming
|
// Tab A starts streaming
|
||||||
useClaudeChatStore.setState((s) => ({
|
useClaudeChatStore.setState((s) => ({
|
||||||
tabs: s.tabs.map((t) => t.id === "tab-default" ? { ...t, isStreaming: true } : t),
|
tabs: s.tabs.map((t) =>
|
||||||
|
t.id === "tab-default" ? { ...t, isStreaming: true } : t,
|
||||||
|
),
|
||||||
isStreaming: s.activeTabId === "tab-default",
|
isStreaming: s.activeTabId === "tab-default",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Tab B can also be streaming independently
|
// Tab B can also be streaming independently
|
||||||
useClaudeChatStore.setState((s) => ({
|
useClaudeChatStore.setState((s) => ({
|
||||||
tabs: s.tabs.map((t) => t.id === tabB ? { ...t, isStreaming: true } : t),
|
tabs: s.tabs.map((t) =>
|
||||||
|
t.id === tabB ? { ...t, isStreaming: true } : t,
|
||||||
|
),
|
||||||
isStreaming: s.activeTabId === tabB,
|
isStreaming: s.activeTabId === tabB,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -199,13 +244,41 @@ describe("Multi-tab merge triggers", () => {
|
||||||
expect(tabBState.isStreaming).toBe(true);
|
expect(tabBState.isStreaming).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves streaming start time when switching tabs", () => {
|
||||||
|
const chat = useClaudeChatStore.getState();
|
||||||
|
const tabB = chat.createTab();
|
||||||
|
const startedAt = Date.now() - 12_000;
|
||||||
|
|
||||||
|
useClaudeChatStore.setState((s) => ({
|
||||||
|
tabs: s.tabs.map((t) =>
|
||||||
|
t.id === "tab-default"
|
||||||
|
? { ...t, isStreaming: true, streamingStartedAt: startedAt }
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
activeTabId: "tab-default",
|
||||||
|
isStreaming: true,
|
||||||
|
streamingStartedAt: startedAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
chat.setActiveTab(tabB);
|
||||||
|
chat.setActiveTab("tab-default");
|
||||||
|
|
||||||
|
const state = useClaudeChatStore.getState();
|
||||||
|
expect(state.streamingStartedAt).toBe(startedAt);
|
||||||
|
expect(
|
||||||
|
state.tabs.find((t) => t.id === "tab-default")!.streamingStartedAt,
|
||||||
|
).toBe(startedAt);
|
||||||
|
});
|
||||||
|
|
||||||
it("_appendMessage routes to the specified tab, not the active tab", () => {
|
it("_appendMessage routes to the specified tab, not the active tab", () => {
|
||||||
const chat = useClaudeChatStore.getState();
|
const chat = useClaudeChatStore.getState();
|
||||||
const tabB = chat.createTab();
|
const tabB = chat.createTab();
|
||||||
|
|
||||||
// Mark tab A as streaming
|
// Mark tab A as streaming
|
||||||
useClaudeChatStore.setState((s) => ({
|
useClaudeChatStore.setState((s) => ({
|
||||||
tabs: s.tabs.map((t) => t.id === "tab-default" ? { ...t, isStreaming: true } : t),
|
tabs: s.tabs.map((t) =>
|
||||||
|
t.id === "tab-default" ? { ...t, isStreaming: true } : t,
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// User is viewing tab B (active), but message is for tab A
|
// User is viewing tab B (active), but message is for tab A
|
||||||
|
|
@ -221,13 +294,98 @@ describe("Multi-tab merge triggers", () => {
|
||||||
const tabBState = state.tabs.find((t) => t.id === tabB)!;
|
const tabBState = state.tabs.find((t) => t.id === tabB)!;
|
||||||
|
|
||||||
expect(tabAState.messages).toHaveLength(1);
|
expect(tabAState.messages).toHaveLength(1);
|
||||||
expect(tabAState.messages[0].message?.content?.[0].text).toBe("Hello from stream");
|
expect(tabAState.messages[0].message?.content?.[0].text).toBe(
|
||||||
|
"Hello from stream",
|
||||||
|
);
|
||||||
expect(tabBState.messages).toHaveLength(0);
|
expect(tabBState.messages).toHaveLength(0);
|
||||||
|
|
||||||
// Top-level projected messages should reflect the active tab (tab B) — empty
|
// Top-level projected messages should reflect the active tab (tab B) — empty
|
||||||
expect(state.messages).toHaveLength(0);
|
expect(state.messages).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("_appendMessage merges direct provider streaming deltas", () => {
|
||||||
|
const chat = useClaudeChatStore.getState();
|
||||||
|
|
||||||
|
chat._appendMessage("tab-default", {
|
||||||
|
type: "assistant",
|
||||||
|
subtype: "streaming_delta",
|
||||||
|
message: { content: [{ type: "text", text: "Hello" }] },
|
||||||
|
});
|
||||||
|
chat._appendMessage("tab-default", {
|
||||||
|
type: "assistant",
|
||||||
|
subtype: "streaming_delta",
|
||||||
|
message: { content: [{ type: "text", text: " world" }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = useClaudeChatStore.getState().messages;
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messages[0].message?.content?.[0].text).toBe("Hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("_appendMessage merges direct provider streaming thinking deltas", () => {
|
||||||
|
const chat = useClaudeChatStore.getState();
|
||||||
|
|
||||||
|
chat._appendMessage("tab-default", {
|
||||||
|
type: "assistant",
|
||||||
|
subtype: "streaming_delta",
|
||||||
|
message: {
|
||||||
|
content: [
|
||||||
|
{ type: "thinking", thinking: "Reason A. " },
|
||||||
|
{ type: "text", text: "Hello" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
chat._appendMessage("tab-default", {
|
||||||
|
type: "assistant",
|
||||||
|
subtype: "streaming_delta",
|
||||||
|
message: {
|
||||||
|
content: [
|
||||||
|
{ type: "thinking", thinking: "Reason B." },
|
||||||
|
{ type: "text", text: " world" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = useClaudeChatStore.getState().messages;
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messages[0].message?.content?.[0].type).toBe("thinking");
|
||||||
|
expect(messages[0].message?.content?.[0].thinking).toBe(
|
||||||
|
"Reason A. Reason B.",
|
||||||
|
);
|
||||||
|
expect(messages[0].message?.content?.[1].text).toBe("Hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("_appendMessage replaces streaming deltas with final direct provider message", () => {
|
||||||
|
const chat = useClaudeChatStore.getState();
|
||||||
|
|
||||||
|
chat._appendMessage("tab-default", {
|
||||||
|
type: "assistant",
|
||||||
|
subtype: "streaming_delta",
|
||||||
|
message: { content: [{ type: "text", text: "Draft" }] },
|
||||||
|
});
|
||||||
|
chat._appendMessage("tab-default", {
|
||||||
|
type: "assistant",
|
||||||
|
subtype: "streaming_final",
|
||||||
|
message: {
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Final" },
|
||||||
|
{
|
||||||
|
type: "tool_use",
|
||||||
|
id: "tool-1",
|
||||||
|
name: "Read",
|
||||||
|
input: { file_path: "main.tex" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = useClaudeChatStore.getState().messages;
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messages[0].subtype).toBe("streaming_final");
|
||||||
|
expect(messages[0].message?.content?.[0].text).toBe("Final");
|
||||||
|
expect(messages[0].message?.content?.[1].type).toBe("tool_use");
|
||||||
|
});
|
||||||
|
|
||||||
it("_setSessionId routes to the specified tab", () => {
|
it("_setSessionId routes to the specified tab", () => {
|
||||||
const chat = useClaudeChatStore.getState();
|
const chat = useClaudeChatStore.getState();
|
||||||
const tabB = chat.createTab();
|
const tabB = chat.createTab();
|
||||||
|
|
@ -257,7 +415,9 @@ describe("Multi-tab merge triggers", () => {
|
||||||
chat._setStreaming("tab-default", false);
|
chat._setStreaming("tab-default", false);
|
||||||
|
|
||||||
const state = useClaudeChatStore.getState();
|
const state = useClaudeChatStore.getState();
|
||||||
expect(state.tabs.find((t) => t.id === "tab-default")!.isStreaming).toBe(false);
|
expect(state.tabs.find((t) => t.id === "tab-default")!.isStreaming).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
expect(state.tabs.find((t) => t.id === tabB)!.isStreaming).toBe(true);
|
expect(state.tabs.find((t) => t.id === tabB)!.isStreaming).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -327,7 +487,9 @@ describe("Multi-tab merge triggers", () => {
|
||||||
chat.setActiveTab(tabB);
|
chat.setActiveTab(tabB);
|
||||||
|
|
||||||
expect(useProposedChangesStore.getState().changes).toHaveLength(1);
|
expect(useProposedChangesStore.getState().changes).toHaveLength(1);
|
||||||
expect(useProposedChangesStore.getState().changes[0].newContent).toBe("after");
|
expect(useProposedChangesStore.getState().changes[0].newContent).toBe(
|
||||||
|
"after",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keepAll clears all changes regardless of which tab is active", () => {
|
it("keepAll clears all changes regardless of which tab is active", () => {
|
||||||
|
|
@ -335,12 +497,20 @@ describe("Multi-tab merge triggers", () => {
|
||||||
const tabB = chat.createTab();
|
const tabB = chat.createTab();
|
||||||
|
|
||||||
useProposedChangesStore.getState().addChange({
|
useProposedChangesStore.getState().addChange({
|
||||||
id: "edit-1", filePath: "main.tex", absolutePath: "/project/main.tex",
|
id: "edit-1",
|
||||||
oldContent: "old-main", newContent: "new-main", toolName: "Edit",
|
filePath: "main.tex",
|
||||||
|
absolutePath: "/project/main.tex",
|
||||||
|
oldContent: "old-main",
|
||||||
|
newContent: "new-main",
|
||||||
|
toolName: "Edit",
|
||||||
});
|
});
|
||||||
useProposedChangesStore.getState().addChange({
|
useProposedChangesStore.getState().addChange({
|
||||||
id: "edit-2", filePath: "refs.bib", absolutePath: "/project/refs.bib",
|
id: "edit-2",
|
||||||
oldContent: "old-bib", newContent: "new-bib", toolName: "Write",
|
filePath: "refs.bib",
|
||||||
|
absolutePath: "/project/refs.bib",
|
||||||
|
oldContent: "old-bib",
|
||||||
|
newContent: "new-bib",
|
||||||
|
toolName: "Write",
|
||||||
});
|
});
|
||||||
|
|
||||||
chat.setActiveTab(tabB);
|
chat.setActiveTab(tabB);
|
||||||
|
|
@ -368,7 +538,9 @@ describe("Multi-tab merge triggers", () => {
|
||||||
chat.closeTab(tabB);
|
chat.closeTab(tabB);
|
||||||
|
|
||||||
expect(useProposedChangesStore.getState().changes).toHaveLength(1);
|
expect(useProposedChangesStore.getState().changes).toHaveLength(1);
|
||||||
expect(useProposedChangesStore.getState().changes[0].id).toBe("from-tab-b");
|
expect(useProposedChangesStore.getState().changes[0].id).toBe(
|
||||||
|
"from-tab-b",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creating a new tab does not clear existing proposed changes", () => {
|
it("creating a new tab does not clear existing proposed changes", () => {
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,18 @@ describe("useProjectStore", () => {
|
||||||
expect(recentProjects[1].path).toBe("/b");
|
expect(recentProjects[1].path).toBe("/b");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes trailing separators when deduplicating", () => {
|
||||||
|
const store = useProjectStore.getState();
|
||||||
|
store.addRecentProject("C:\\Projects\\ClaudePrism\\paper\\");
|
||||||
|
store.addRecentProject("C:\\Projects\\ClaudePrism\\paper");
|
||||||
|
const { recentProjects } = useProjectStore.getState();
|
||||||
|
expect(recentProjects).toHaveLength(1);
|
||||||
|
expect(recentProjects[0]).toMatchObject({
|
||||||
|
path: "C:\\Projects\\ClaudePrism\\paper",
|
||||||
|
name: "paper",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("limits to MAX_RECENT (10) entries", () => {
|
it("limits to MAX_RECENT (10) entries", () => {
|
||||||
const store = useProjectStore.getState();
|
const store = useProjectStore.getState();
|
||||||
for (let i = 0; i < 12; i++) {
|
for (let i = 0; i < 12; i++) {
|
||||||
|
|
@ -43,12 +55,16 @@ describe("useProjectStore", () => {
|
||||||
|
|
||||||
it("extracts name from path correctly", () => {
|
it("extracts name from path correctly", () => {
|
||||||
useProjectStore.getState().addRecentProject("/a/b/c/deep-folder");
|
useProjectStore.getState().addRecentProject("/a/b/c/deep-folder");
|
||||||
expect(useProjectStore.getState().recentProjects[0].name).toBe("deep-folder");
|
expect(useProjectStore.getState().recentProjects[0].name).toBe(
|
||||||
|
"deep-folder",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses full path as name if no segments", () => {
|
it("uses full path as name if no segments", () => {
|
||||||
useProjectStore.getState().addRecentProject("standalone");
|
useProjectStore.getState().addRecentProject("standalone");
|
||||||
expect(useProjectStore.getState().recentProjects[0].name).toBe("standalone");
|
expect(useProjectStore.getState().recentProjects[0].name).toBe(
|
||||||
|
"standalone",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -63,4 +79,38 @@ describe("useProjectStore", () => {
|
||||||
expect(recentProjects[0].path).toBe("/b");
|
expect(recentProjects[0].path).toBe("/b");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("renameRecentProject", () => {
|
||||||
|
it("replaces the old recent project path with the new folder path", () => {
|
||||||
|
const store = useProjectStore.getState();
|
||||||
|
store.addRecentProject("/work/old");
|
||||||
|
store.addRecentProject("/work/other");
|
||||||
|
store.renameRecentProject("/work/old", "/work/new");
|
||||||
|
|
||||||
|
const { recentProjects } = useProjectStore.getState();
|
||||||
|
expect(recentProjects[0]).toMatchObject({
|
||||||
|
path: "/work/new",
|
||||||
|
name: "new",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
recentProjects.some((project) => project.path === "/work/old"),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
recentProjects.some((project) => project.path === "/work/other"),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches renamed paths even when the old recent path has a trailing slash", () => {
|
||||||
|
const store = useProjectStore.getState();
|
||||||
|
store.addRecentProject("/work/old/");
|
||||||
|
store.renameRecentProject("/work/old", "/work/new/");
|
||||||
|
|
||||||
|
const { recentProjects } = useProjectStore.getState();
|
||||||
|
expect(recentProjects).toHaveLength(1);
|
||||||
|
expect(recentProjects[0]).toMatchObject({
|
||||||
|
path: "/work/new",
|
||||||
|
name: "new",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -111,13 +111,17 @@ describe("useProposedChangesStore", () => {
|
||||||
newContent: "b",
|
newContent: "b",
|
||||||
toolName: "Edit",
|
toolName: "Edit",
|
||||||
});
|
});
|
||||||
const change = useProposedChangesStore.getState().getChangeForFile("main.tex");
|
const change = useProposedChangesStore
|
||||||
|
.getState()
|
||||||
|
.getChangeForFile("main.tex");
|
||||||
expect(change).toBeDefined();
|
expect(change).toBeDefined();
|
||||||
expect(change!.id).toBe("tool-1");
|
expect(change!.id).toBe("tool-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns undefined for unknown file", () => {
|
it("returns undefined for unknown file", () => {
|
||||||
const change = useProposedChangesStore.getState().getChangeForFile("nonexistent.tex");
|
const change = useProposedChangesStore
|
||||||
|
.getState()
|
||||||
|
.getChangeForFile("nonexistent.tex");
|
||||||
expect(change).toBeUndefined();
|
expect(change).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,9 @@ describe("useTemplateStore", () => {
|
||||||
useTemplateStore.getState().setSelectedCategory("academic");
|
useTemplateStore.getState().setSelectedCategory("academic");
|
||||||
const { filteredTemplates } = useTemplateStore.getState();
|
const { filteredTemplates } = useTemplateStore.getState();
|
||||||
expect(filteredTemplates.length).toBeGreaterThan(0);
|
expect(filteredTemplates.length).toBeGreaterThan(0);
|
||||||
expect(filteredTemplates.every((t) => t.category === "academic")).toBe(true);
|
expect(filteredTemplates.every((t) => t.category === "academic")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears category filter with null", () => {
|
it("clears category filter with null", () => {
|
||||||
|
|
@ -53,7 +55,9 @@ describe("useTemplateStore", () => {
|
||||||
useTemplateStore.getState().setSelectedCategory("academic");
|
useTemplateStore.getState().setSelectedCategory("academic");
|
||||||
const { filteredTemplates } = useTemplateStore.getState();
|
const { filteredTemplates } = useTemplateStore.getState();
|
||||||
expect(filteredTemplates.length).toBeGreaterThan(0);
|
expect(filteredTemplates.length).toBeGreaterThan(0);
|
||||||
expect(filteredTemplates.every((t) => t.category === "academic")).toBe(true);
|
expect(filteredTemplates.every((t) => t.category === "academic")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,10 @@ function storeKey(collectionKey: string | null): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeFileName(name: string): string {
|
function sanitizeFileName(name: string): string {
|
||||||
return name.replace(/[^a-zA-Z0-9_\-\s]/g, "").replace(/\s+/g, "-").toLowerCase();
|
return name
|
||||||
|
.replace(/[^a-zA-Z0-9_\-\s]/g, "")
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseBibEntries(content: string): Map<string, string> {
|
function parseBibEntries(content: string): Map<string, string> {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { resolve } from "path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates tauri.conf.json CSP (Content Security Policy) configuration.
|
* Validates tauri.conf.json CSP (Content Security Policy) configuration.
|
||||||
|
|
|
||||||
3
apps/desktop/src/assets/providers/anthropic.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="248" height="248" viewBox="0 0 248 248" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z" fill="#D97757"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
14
apps/desktop/src/assets/providers/deepseek.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 377.1 277.86">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #4d6bfe;
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<path id="path" class="cls-1" d="M373.15,23.32c-4-1.95-5.72,1.77-8.06,3.66-.79.62-1.47,1.43-2.14,2.14-5.85,6.26-12.67,10.36-21.57,9.86-13.04-.71-24.16,3.38-33.99,13.37-2.09-12.31-9.04-19.66-19.6-24.38-5.54-2.45-11.13-4.9-14.99-10.23-2.71-3.78-3.44-8-4.81-12.16-.85-2.51-1.72-5.09-4.6-5.52-3.13-.5-4.36,2.14-5.58,4.34-4.93,8.99-6.82,18.92-6.65,28.97.43,22.58,9.97,40.56,28.89,53.37,2.16,1.46,2.71,2.95,2.03,5.09-1.29,4.4-2.82,8.68-4.19,13.09-.85,2.82-2.14,3.44-5.15,2.2-10.39-4.34-19.37-10.76-27.29-18.55-13.46-13.02-25.63-27.41-40.81-38.67-3.57-2.64-7.12-5.09-10.81-7.41-15.49-15.07,2.03-27.45,6.08-28.9,4.25-1.52,1.47-6.79-12.23-6.73-13.69.06-26.24,4.65-42.21,10.76-2.34.93-4.79,1.61-7.32,2.14-14.5-2.73-29.55-3.35-45.29-1.58-29.62,3.32-53.28,17.34-70.68,41.28C1.29,88.2-3.63,120.88,2.39,155c6.33,35.91,24.64,65.68,52.8,88.94,29.18,24.1,62.8,35.91,101.15,33.65,23.29-1.33,49.23-4.46,78.48-29.24,7.38,3.66,15.12,5.12,27.97,6.23,9.89.93,19.41-.5,26.79-2.02,11.55-2.45,10.75-13.15,6.58-15.13-33.87-15.78-26.44-9.36-33.2-14.54,17.21-20.41,43.15-41.59,53.3-110.19.79-5.46.11-8.87,0-13.3-.06-2.67.54-3.72,3.61-4.03,8.48-.96,16.72-3.29,24.28-7.47,21.94-12,30.78-31.69,32.87-55.33.31-3.6-.06-7.35-3.86-9.24ZM181.96,235.97c-32.83-25.83-48.74-34.33-55.31-33.96-6.14.34-5.04,7.38-3.69,11.97,1.41,4.53,3.26,7.66,5.85,11.63,1.78,2.64,3.01,6.57-1.78,9.49-10.57,6.58-28.95-2.2-29.82-2.64-21.38-12.59-39.26-29.24-51.87-52.01-12.16-21.92-19.23-45.43-20.39-70.52-.31-6.08,1.47-8.22,7.49-9.3,7.92-1.46,16.11-1.77,24.03-.62,33.49,4.9,62.01,19.91,85.9,43.63,13.65,13.55,23.97,29.71,34.61,45.49,11.3,16.78,23.48,32.75,38.97,45.84,5.46,4.59,9.83,8.09,14,10.67-12.59,1.4-33.62,1.71-47.99-9.68ZM197.69,134.65c0-2.7,2.15-4.84,4.87-4.84.6,0,1.16.12,1.66.31.67.25,1.29.62,1.77,1.18.87.84,1.36,2.08,1.36,3.35,0,2.7-2.15,4.84-4.85,4.84s-4.81-2.14-4.81-4.84ZM246.55,159.77c-3.13,1.27-6.26,2.39-9.27,2.51-4.67.22-9.77-1.68-12.55-4-4.3-3.6-7.36-5.61-8.67-11.94-.54-2.7-.23-6.85.25-9.24,1.12-5.15-.12-8.44-3.74-11.44-2.96-2.45-6.7-3.1-10.82-3.1-1.54,0-2.95-.68-4-1.24-1.72-.87-3.13-3.01-1.78-5.64.43-.84,2.53-2.92,3.02-3.29,5.58-3.19,12.03-2.14,18,.25,5.54,2.26,9.71,6.42,15.72,12.28,6.16,7.1,7.26,9.09,10.76,14.39,2.76,4.19,5.29,8.47,7.01,13.37,1.04,3.04-.31,5.55-3.94,7.1Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
1
apps/desktop/src/assets/providers/gemini-color.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-0-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-1-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-2-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-0-_R_0_" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-1-_R_0_" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-2-_R_0_" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
1
apps/desktop/src/assets/providers/moonshot.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>MoonshotAI</title><path d="M1.052 16.916l9.539 2.552a21.007 21.007 0 00.06 2.033l5.956 1.593a11.997 11.997 0 01-5.586.865l-.18-.016-.044-.004-.084-.009-.094-.01a11.605 11.605 0 01-.157-.02l-.107-.014-.11-.016a11.962 11.962 0 01-.32-.051l-.042-.008-.075-.013-.107-.02-.07-.015-.093-.019-.075-.016-.095-.02-.097-.023-.094-.022-.068-.017-.088-.022-.09-.024-.095-.025-.082-.023-.109-.03-.062-.02-.084-.025-.093-.028-.105-.034-.058-.019-.08-.026-.09-.031-.066-.024a6.293 6.293 0 01-.044-.015l-.068-.025-.101-.037-.057-.022-.08-.03-.087-.035-.088-.035-.079-.032-.095-.04-.063-.028-.063-.027a5.655 5.655 0 01-.041-.018l-.066-.03-.103-.047-.052-.024-.096-.046-.062-.03-.084-.04-.086-.044-.093-.047-.052-.027-.103-.055-.057-.03-.058-.032a6.49 6.49 0 01-.046-.026l-.094-.053-.06-.034-.051-.03-.072-.041-.082-.05-.093-.056-.052-.032-.084-.053-.061-.039-.079-.05-.07-.047-.053-.035a7.785 7.785 0 01-.054-.036l-.044-.03-.044-.03a6.066 6.066 0 01-.04-.028l-.057-.04-.076-.054-.069-.05-.074-.054-.056-.042-.076-.057-.076-.059-.086-.067-.045-.035-.064-.052-.074-.06-.089-.073-.046-.039-.046-.039a7.516 7.516 0 01-.043-.037l-.045-.04-.061-.053-.07-.062-.068-.06-.062-.058-.067-.062-.053-.05-.088-.084a13.28 13.28 0 01-.099-.097l-.029-.028-.041-.042-.069-.07-.05-.051-.05-.053a6.457 6.457 0 01-.168-.179l-.08-.088-.062-.07-.071-.08-.042-.049-.053-.062-.058-.068-.046-.056a7.175 7.175 0 01-.027-.033l-.045-.055-.066-.082-.041-.052-.05-.064-.02-.025a11.99 11.99 0 01-1.44-2.402zm-1.02-5.794l11.353 3.037a20.468 20.468 0 00-.469 2.011l10.817 2.894a12.076 12.076 0 01-1.845 2.005L.657 15.923l-.016-.046-.035-.104a11.965 11.965 0 01-.05-.153l-.007-.023a11.896 11.896 0 01-.207-.741l-.03-.126-.018-.08-.021-.097-.018-.081-.018-.09-.017-.084-.018-.094c-.026-.141-.05-.283-.071-.426l-.017-.118-.011-.083-.013-.102a12.01 12.01 0 01-.019-.161l-.005-.047a12.12 12.12 0 01-.034-2.145zm1.593-5.15l11.948 3.196c-.368.605-.705 1.231-1.01 1.875l11.295 3.022c-.142.82-.368 1.612-.668 2.365l-11.55-3.09L.124 10.26l.015-.1.008-.049.01-.067.015-.087.018-.098c.026-.148.056-.295.088-.442l.028-.124.02-.085.024-.097c.022-.09.045-.18.07-.268l.028-.102.023-.083.03-.1.025-.082.03-.096.026-.082.031-.095a11.896 11.896 0 011.01-2.232zm4.442-4.4L17.352 4.59a20.77 20.77 0 00-1.688 1.721l7.823 2.093c.267.852.442 1.744.513 2.665L2.106 5.213l.045-.065.027-.04.04-.055.046-.065.055-.076.054-.072.064-.086.05-.065.057-.073.055-.07.06-.074.055-.069.065-.077.054-.066.066-.077.053-.06.072-.082.053-.06.067-.074.054-.058.073-.078.058-.06.063-.067.168-.17.1-.098.059-.056.076-.071a12.084 12.084 0 012.272-1.677zM12.017 0h.097l.082.001.069.001.054.002.068.002.046.001.076.003.047.002.06.003.054.002.087.005.105.007.144.011.088.007.044.004.077.008.082.008.047.005.102.012.05.006.108.014.081.01.042.006.065.01.207.032.07.012.065.011.14.026.092.018.11.022.046.01.075.016.041.01L14.7.3l.042.01.065.015.049.012.071.017.096.024.112.03.113.03.113.032.05.015.07.02.078.024.073.023.05.016.05.016.076.025.099.033.102.036.048.017.064.023.093.034.11.041.116.045.1.04.047.02.06.024.041.018.063.026.04.018.057.025.11.048.1.046.074.035.075.036.06.028.092.046.091.045.102.052.053.028.049.026.046.024.06.033.041.022.052.029.088.05.106.06.087.051.057.034.053.032.096.059.088.055.098.062.036.024.064.041.084.056.04.027.062.042.062.043.023.017c.054.037.108.075.161.114l.083.06.065.048.056.043.086.065.082.064.04.03.05.041.086.069.079.065.085.071c.712.6 1.353 1.283 1.909 2.031L7.222.994l.062-.027.065-.028.081-.034.086-.035c.113-.045.227-.09.341-.131l.096-.035.093-.033.084-.03.096-.031c.087-.03.176-.058.264-.085l.091-.027.086-.025.102-.03.085-.023.1-.026L9.04.37l.09-.023.091-.022.095-.022.09-.02.098-.021.091-.02.095-.018.092-.018.1-.018.091-.016.098-.017.092-.014.097-.015.092-.013.102-.013.091-.012.105-.012.09-.01.105-.01c.093-.01.186-.018.28-.024l.106-.008.09-.005.11-.006.093-.004.1-.004.097-.002.099-.002.197-.002z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
1
apps/desktop/src/assets/providers/ollama.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" viewBox="0 0 512 512"><circle cx="256" cy="256" r="256" style="fill:#fff"/><defs><circle id="ollama_svg__a" cx="256" cy="256" r="256"/></defs><clipPath id="ollama_svg__b"><use xlink:href="#ollama_svg__a" style="overflow:visible"/></clipPath><g style="clip-path:url(#ollama_svg__b)"><path d="M157.3 35.9c-4.3.7-9.5 3-13.1 5.7-11 8.4-19.6 26.2-23.2 48.4-1.4 8.4-2.3 20-2.3 28.9 0 10.5 1.2 23.9 3 33.1.4 2.1.6 3.9.4 4-.1.1-1.8 1.5-3.6 2.9-6.2 5-13.4 12.6-18.3 19.6-9.4 13.4-15.5 28.6-18.1 45-1 6.5-1.3 19.6-.5 26.1 1.8 15 6.3 27.7 14 39.3l2.5 3.7-.7 1.2c-5.2 8.7-9.6 21.3-11.6 33.3-1.6 9.6-1.8 12.1-1.8 24.9 0 12.9.2 15.5 1.7 24.4 1.8 10.7 5.5 22 9.7 29.5 1.4 2.5 4.7 7.6 5.1 7.8.1.1-.3 1.3-.9 2.7-4.8 10.5-8.9 24.4-10.6 36.1-1.2 8-1.4 10.6-1.4 19.1 0 10.8.6 16 2.9 24.6l.3 1.3h28.4l-.9-1.8c-5.7-10.6-6.3-30.3-1.3-50 2.3-9.1 4.8-15.8 9.6-24.9l2.9-5.6v-3.4c0-3.2-.1-3.5-1.1-5.6-.8-1.6-1.9-3-3.7-4.8-3.2-3.1-5.5-6.4-7.4-10.5-8.2-17.7-9.8-44-4-66.5 2.4-9.4 6.3-17.7 10.5-22.2 2.8-3.1 4.3-6.6 4.3-10.2 0-3.7-1.3-6.8-4.3-10.1-8.6-9.2-13.8-20.3-15.7-33.3-2.7-18.5 2.2-38.6 13.3-54.6 10.8-15.7 26.1-25.7 43.1-28.4 3.8-.6 10.9-.5 14.9.2 4.3.8 7.1.5 9.9-.8 3.5-1.6 5.2-3.6 7.2-8.3 1.8-4.1 3.2-6.4 6.9-11.1 4.5-5.6 8.9-9.4 15.8-14 8-5.2 17-9 26-10.8 3.3-.7 4.8-.8 10.9-.8s7.7.1 10.9.8c13.2 2.7 26.4 9.5 36.9 19.2 2.3 2.1 7.7 8.8 9.4 11.6.7 1.1 1.8 3.4 2.6 5.1 2 4.6 3.7 6.7 7.2 8.3 2.7 1.3 5.5 1.6 9.7.9 6.6-1.1 11.7-1 18.1.3 22 4.4 41.2 22.6 49.7 46.9 7.4 21.3 5.3 43.7-5.7 60.7-1.9 2.9-3.7 5.2-6.4 8.1-5.8 6.2-5.8 13.9 0 20.3 9.5 10.4 15.4 35.9 13.6 58.5-1.2 14.9-5 28.2-10.3 35.7-.9 1.3-2.9 3.6-4.3 5-1.9 1.9-3 3.2-3.7 4.8-1 2.1-1.1 2.5-1.1 5.6v3.4l2.9 5.6c4.8 9.2 7.3 15.9 9.6 24.9 4.9 19.4 4.4 38.7-1.1 49.7-.5.9-.9 1.8-.9 1.9s6.3.2 14.1.2h14.1l.4-1.4c.2-.8.5-1.9.7-2.6.4-1.5 1.1-5.8 1.7-9.9.6-4.2.6-19.6 0-24.2-2.1-16.9-5.7-30.2-11.5-42.9-.6-1.4-1-2.7-.9-2.7.2-.1 1.1-1.4 2.1-2.9 7.2-10.9 11.7-24.7 13.9-42.9.6-5 .6-26.5 0-31.4-1.6-12.4-3.5-20.8-6.7-29.4-1.3-3.5-4.8-11-6.3-13.5l-.7-1.2 2.5-3.7c7.7-11.6 12.2-24.3 14-39.3.8-6.5.5-19.6-.5-26.1-2.6-16.5-8.7-31.6-18.1-45-4.9-7-12-14.7-18.3-19.6-1.8-1.5-3.5-2.8-3.6-2.9-.2-.1 0-2 .4-4 4-20.9 3.9-47-.3-67.4-3.6-17.8-10.3-31.9-18.8-40.1-6.8-6.5-13.8-9.3-22.2-8.8-19.2 1.1-34.6 23.2-40.7 58-1 5.6-1.9 12.2-1.9 14 0 .7-.1 1.3-.3 1.3s-1.5-.7-2.9-1.5C288.5 98.8 272 94.1 256 94.1s-32.5 4.7-47.3 13.4c-1.4.8-2.7 1.5-2.9 1.5s-.3-.6-.3-1.3c0-1.9-.9-8.6-1.9-14-5.5-31.2-18.2-51.9-35.1-57.1-2.2-.6-8.8-1.1-11.2-.7m5.6 27c4.8 3.8 10.1 14.6 13.1 26.7.6 2.2 1.2 4.7 1.3 5.6s.5 2.9.8 4.5c1.3 7 1.9 14.6 2 23.9v9.1l-2.3 3.4-2.3 3.4h-5.3c-6.2 0-12.4.8-18.4 2.4-2.1.5-4.2 1.1-4.6 1.2-.6.1-.7-.1-1.1-2.8-2-14.8-1.9-31.1.3-44.7 2.4-15.2 8-28.9 13.4-32.9 1.4-1 1.6-1 3.1.2m189.2-.2c3.3 2.4 6.9 8.9 9.6 17.1 5.4 16.5 6.9 39 4.1 60.5-.4 2.7-.5 2.9-1.1 2.8-.4-.1-2.5-.6-4.6-1.2-5.9-1.6-12.1-2.4-18.4-2.4h-5.3l-2.3-3.4-2.3-3.4v-9.1c.1-12.9 1.3-22.9 4.1-34.1 3-12 8.4-22.8 13.1-26.6 1.6-1.2 1.8-1.2 3.1-.2"/><path d="M250.9 229.6c-7.2.7-9.2 1-12.6 1.7-5.6 1.2-13.1 3.7-18.3 6.3-18.1 8.9-30.6 23.6-34.4 40.7-.8 3.4-.9 4.5-.9 10.2 0 5.6.1 6.9.8 10.1 5.1 22.3 25.6 38.8 52.3 41.8 5.8.6 30.7.6 36.5 0 21.4-2.4 39.7-14 48-30.3 2.2-4.3 3.3-7.2 4.2-11.6.7-3.2.8-4.4.8-10.1s-.1-6.8-.9-10.2c-5.5-24.8-29.6-44.4-59.2-48.1-3.7-.3-13.8-.7-16.3-.5m12.4 18.1c9.9 1.1 19.8 4.6 27.7 9.9 4.3 2.9 10.3 8.8 12.9 12.7 3.2 4.8 5 9.8 5.8 15.8.4 2.8.2 4.8-.8 9.3-1.6 6.6-6.4 13.6-12.9 18.4-3.1 2.2-9.4 5.4-13.3 6.7-7.4 2.4-12.2 2.8-29.4 2.7-11.2-.1-13.2-.2-16.4-.8-11-2.1-19.7-6.4-26-13.1-5.1-5.4-7.4-10.3-8.7-18.2-.6-3.7.5-9.8 2.7-14.9 2.6-6.3 9.4-14.1 16.1-18.5 7.8-5.2 18-8.9 27.4-9.9 3.6-.5 11.2-.5 14.9-.1"/><path d="M243.3 271.9c-2.5 1.4-4.3 4.8-3.7 7.4.6 2.8 3 5.5 6.8 7.8 2 1.2 2.2 1.4 2.3 2.6.1.7-.2 2.8-.6 4.7-.4 1.8-.7 3.7-.7 4.3 0 1.4 1.4 3.7 2.8 4.9 1.2 1 1.5 1 4.9 1.1 3.2.1 3.8 0 5.1-.6 3.3-1.6 4.1-4.5 2.9-10.1-1-4.7-.8-5.4 1.7-6.8 2.6-1.5 5.4-4.2 6.2-6 1.6-3.5.1-7.4-3.4-9.3-.9-.4-1.9-.6-3.5-.6-2.4 0-4 .6-6.8 2.4l-1.6 1-1-.6c-4.2-2.5-5-2.8-7.5-2.8-2 0-3 .1-3.9.6m-80.5-38.5c-5.9 1.9-10.3 6.2-12.5 12.3-1.1 2.9-1.6 7.5-1.2 10 1.1 5.9 6 11.3 11.5 12.8 7 1.8 12.2.6 16.8-3.9 2.7-2.6 4.1-4.9 5.6-8.6 1.1-2.6 1.1-3.1 1.1-6.8v-4l-1.4-2.9c-2.2-4.5-6.2-7.9-10.9-9.1-2.5-.6-6.7-.6-9 .2m177.2-.1c-4.5 1.2-8.6 4.6-10.7 9.1l-1.4 2.9v4c0 3.7.1 4.2 1.1 6.8 1.5 3.7 2.9 6 5.6 8.6 4.6 4.6 9.8 5.8 16.8 3.9 4-1.1 8-4.4 10-8.4 1.7-3.4 2.1-5.8 1.5-9.6-1.2-8.7-6.3-15.1-13.9-17.3-2.3-.7-6.6-.7-9 0"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 4.4 KiB |
1
apps/desktop/src/assets/providers/openai.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
15
apps/desktop/src/assets/providers/qwen.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="27.55 17.52 147.28 145.51">
|
||||||
|
<path d="M174.82 108.75L155.38 75L165.64 57.75C166.46 56.31 166.46 54.53 165.64 53.09L155.38 35.84C154.86 34.91 153.87 34.33 152.78 34.33H114.88L106.14 19.03C105.62 18.1 104.63 17.52 103.54 17.52H83.3C82.21 17.52 81.22 18.1 80.7 19.03L61.26 52.77H41.02C39.93 52.77 38.94 53.35 38.42 54.28L28.16 71.53C27.34 72.97 27.34 74.75 28.16 76.19L45.52 107.5L36.78 122.8C35.96 124.24 35.96 126.02 36.78 127.46L47.04 144.71C47.56 145.64 48.55 146.22 49.64 146.22H87.54L96.28 161.52C96.8 162.45 97.79 163.03 98.88 163.03H119.12C120.21 163.03 121.2 162.45 121.72 161.52L141.16 127.78H158.52C159.61 127.78 160.6 127.2 161.12 126.27L171.38 109.02C172.2 107.58 172.2 105.8 171.38 104.36L174.82 108.75Z" fill="url(#paint0_radial)"/>
|
||||||
|
<path d="M119.12 163.03H98.88L87.54 144.71H49.64L61.26 126.39H80.7L38.42 55.29H61.26L83.3 19.03L93.56 37.35L83.3 55.29H161.58L151.32 72.54L170.76 106.28H151.32L141.16 88.34L101.18 163.03H119.12Z" fill="white"/>
|
||||||
|
<path d="M127.86 79.83H76.14L101.18 122.11L127.86 79.83Z" fill="url(#paint1_radial)"/>
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="paint0_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(100 100) rotate(90) scale(100)">
|
||||||
|
<stop stop-color="#665CEE"/>
|
||||||
|
<stop offset="1" stop-color="#332E91"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="paint1_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(100 100) rotate(90) scale(100)">
|
||||||
|
<stop stop-color="#665CEE"/>
|
||||||
|
<stop offset="1" stop-color="#332E91"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
1
apps/desktop/src/assets/providers/zhipu-color.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
1
apps/desktop/src/assets/vscode.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><mask id="a" width="128" height="128" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#fff" fill-rule="evenodd" d="M90.767 127.126a7.968 7.968 0 0 0 6.35-.244l26.353-12.681a8 8 0 0 0 4.53-7.209V21.009a8 8 0 0 0-4.53-7.21L97.117 1.12a7.97 7.97 0 0 0-9.093 1.548l-50.45 46.026L15.6 32.013a5.328 5.328 0 0 0-6.807.302l-7.048 6.411a5.335 5.335 0 0 0-.006 7.888L20.796 64 1.74 81.387a5.336 5.336 0 0 0 .006 7.887l7.048 6.411a5.327 5.327 0 0 0 6.807.303l21.974-16.68 50.45 46.025a7.96 7.96 0 0 0 2.743 1.793Zm5.252-92.183L57.74 64l38.28 29.058V34.943Z" clip-rule="evenodd"/></mask><g mask="url(#a)"><path fill="#0065A9" d="M123.471 13.82 97.097 1.12A7.973 7.973 0 0 0 88 2.668L1.662 81.387a5.333 5.333 0 0 0 .006 7.887l7.052 6.411a5.333 5.333 0 0 0 6.811.303l103.971-78.875c3.488-2.646 8.498-.158 8.498 4.22v-.306a8.001 8.001 0 0 0-4.529-7.208Z"/><g filter="url(#b)"><path fill="#007ACC" d="m123.471 114.181-26.374 12.698A7.973 7.973 0 0 1 88 125.333L1.662 46.613a5.333 5.333 0 0 1 .006-7.887l7.052-6.411a5.333 5.333 0 0 1 6.811-.303l103.971 78.874c3.488 2.647 8.498.159 8.498-4.219v.306a8.001 8.001 0 0 1-4.529 7.208Z"/></g><g filter="url(#c)"><path fill="#1F9CF0" d="M97.098 126.882A7.977 7.977 0 0 1 88 125.333c2.952 2.952 8 .861 8-3.314V5.98c0-4.175-5.048-6.266-8-3.313a7.977 7.977 0 0 1 9.098-1.549L123.467 13.8A8 8 0 0 1 128 21.01v85.982a8 8 0 0 1-4.533 7.21l-26.369 12.681Z"/></g><path fill="url(#d)" fill-rule="evenodd" d="M90.69 127.126a7.968 7.968 0 0 0 6.349-.244l26.353-12.681a8 8 0 0 0 4.53-7.21V21.009a8 8 0 0 0-4.53-7.21L97.039 1.12a7.97 7.97 0 0 0-9.093 1.548l-50.45 46.026-21.974-16.68a5.328 5.328 0 0 0-6.807.302l-7.048 6.411a5.336 5.336 0 0 0-.006 7.888L20.718 64 1.662 81.386a5.335 5.335 0 0 0 .006 7.888l7.048 6.411a5.328 5.328 0 0 0 6.807.303l21.975-16.681 50.45 46.026a7.959 7.959 0 0 0 2.742 1.793Zm5.252-92.184L57.662 64l38.28 29.057V34.943Z" clip-rule="evenodd" opacity="0.25" style="mix-blend-mode:overlay"/></g><defs><filter id="b" width="144.744" height="113.408" x="-8.41115" y="22.5944" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset/><feGaussianBlur stdDeviation="4.16667"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="overlay" result="effect1_dropShadow_1_36"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_1_36" result="shape"/></filter><filter id="c" width="56.6667" height="144.007" x="79.6667" y="-8.0035" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset/><feGaussianBlur stdDeviation="4.16667"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="overlay" result="effect1_dropShadow_1_36"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_1_36" result="shape"/></filter><linearGradient id="d" x1="63.9222" x2="63.9222" y1="0.329902" y2="127.67" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs></svg>
|
||||||
|
After Width: | Height: | Size: 3.3 KiB |
|
|
@ -1,5 +1,3 @@
|
||||||
|
|
||||||
|
|
||||||
import { ComponentPropsWithRef, forwardRef } from "react";
|
import { ComponentPropsWithRef, forwardRef } from "react";
|
||||||
import { Slottable } from "@radix-ui/react-slot";
|
import { Slottable } from "@radix-ui/react-slot";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,125 @@
|
||||||
import { type FC, memo, useEffect, useMemo, useRef, useState } from "react";
|
import { type FC, memo, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { AlertCircleIcon } from "lucide-react";
|
import {
|
||||||
import { useClaudeChatStore, type ClaudeStreamMessage, type ContentBlock } from "@/stores/claude-chat-store";
|
AlertCircleIcon,
|
||||||
|
CheckIcon,
|
||||||
|
CopyIcon,
|
||||||
|
CornerDownRightIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
useClaudeChatStore,
|
||||||
|
type ClaudeStreamMessage,
|
||||||
|
type ContentBlock,
|
||||||
|
type QueuedGuidance,
|
||||||
|
} from "@/stores/claude-chat-store";
|
||||||
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { MarkdownRenderer } from "./markdown-renderer";
|
import { MarkdownRenderer } from "./markdown-renderer";
|
||||||
import { ThinkingWidget, ToolWidget } from "./tool-widgets";
|
import { ThinkingWidget, ToolWidget } from "./tool-widgets";
|
||||||
|
|
||||||
// ─── Streaming Indicator (isolated to prevent re-render storms) ───
|
// ─── Streaming Indicator (isolated to prevent re-render storms) ───
|
||||||
|
|
||||||
const StreamingIndicator: FC = memo(() => {
|
const StreamingIndicator: FC<{ startedAt: number | null }> = memo(
|
||||||
const [elapsed, setElapsed] = useState(0);
|
({ startedAt }) => {
|
||||||
const startRef = useRef(Date.now());
|
const calculateElapsed = () =>
|
||||||
|
startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000)) : 0;
|
||||||
|
|
||||||
useEffect(() => {
|
const [elapsed, setElapsed] = useState(calculateElapsed);
|
||||||
startRef.current = Date.now();
|
|
||||||
setElapsed(0);
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
setElapsed(calculateElapsed());
|
||||||
setElapsed(Math.floor((Date.now() - startRef.current) / 1000));
|
const timer = setInterval(() => {
|
||||||
}, 1000);
|
setElapsed(calculateElapsed());
|
||||||
return () => clearInterval(timer);
|
}, 1000);
|
||||||
}, []);
|
return () => clearInterval(timer);
|
||||||
|
}, [startedAt]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 px-1 py-1.5 text-muted-foreground">
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
<span
|
||||||
|
className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50"
|
||||||
|
style={{ animationDelay: "0ms" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50"
|
||||||
|
style={{ animationDelay: "150ms" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50"
|
||||||
|
style={{ animationDelay: "300ms" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm">
|
||||||
|
Thinking...
|
||||||
|
{elapsed >= 3 && (
|
||||||
|
<span className="ml-1 text-muted-foreground/60 text-xs">
|
||||||
|
{elapsed}s
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const EMPTY_PENDING_GUIDANCE: QueuedGuidance[] = [];
|
||||||
|
const THREAD_MAX_WIDTH = "max-w-[44rem]";
|
||||||
|
|
||||||
|
const MessageActions: FC<{
|
||||||
|
text: string;
|
||||||
|
align?: "left" | "right";
|
||||||
|
}> = ({ text, align = "left" }) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const canCopy = text.trim().length > 0;
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!canCopy) return;
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1200);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!canCopy) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5 px-1 py-1.5 text-muted-foreground">
|
<div
|
||||||
<div className="flex gap-0.5">
|
className={cn(
|
||||||
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50" style={{ animationDelay: "0ms" }} />
|
"flex gap-1 text-muted-foreground",
|
||||||
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50" style={{ animationDelay: "150ms" }} />
|
align === "right" ? "justify-end" : "justify-start",
|
||||||
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50" style={{ animationDelay: "300ms" }} />
|
)}
|
||||||
</div>
|
>
|
||||||
<span className="text-sm">
|
<TooltipIconButton
|
||||||
Thinking...
|
tooltip={copied ? "Copied" : "Copy"}
|
||||||
{elapsed >= 3 && (
|
side="top"
|
||||||
<span className="ml-1 text-xs text-muted-foreground/60">{elapsed}s</span>
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckIcon className="fade-in zoom-in-50 size-4 animate-in duration-200" />
|
||||||
|
) : (
|
||||||
|
<CopyIcon className="fade-in zoom-in-75 size-4 animate-in duration-150" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</TooltipIconButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
// ─── Chat Messages (main component) ───
|
// ─── Chat Messages (main component) ───
|
||||||
|
|
||||||
export const ChatMessages: FC = () => {
|
export const ChatMessages: FC = () => {
|
||||||
const messages = useClaudeChatStore((s) => s.messages) ?? [];
|
const messages = useClaudeChatStore((s) => s.messages) ?? [];
|
||||||
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
|
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
|
||||||
|
const streamingStartedAt = useClaudeChatStore((s) => s.streamingStartedAt);
|
||||||
|
const queuedGuidance =
|
||||||
|
useClaudeChatStore(
|
||||||
|
(s) => s.tabs.find((tab) => tab.id === s.activeTabId)?.queuedGuidance,
|
||||||
|
) ?? EMPTY_PENDING_GUIDANCE;
|
||||||
|
const pendingGuidance = useMemo(
|
||||||
|
() => queuedGuidance.filter((guidance) => guidance.displayedInChat),
|
||||||
|
[queuedGuidance],
|
||||||
|
);
|
||||||
const viewportRef = useRef<HTMLDivElement>(null);
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
const shouldAutoScrollRef = useRef(true);
|
const shouldAutoScrollRef = useRef(true);
|
||||||
const userHasScrolledRef = useRef(false);
|
const userHasScrolledRef = useRef(false);
|
||||||
|
|
@ -76,11 +155,16 @@ export const ChatMessages: FC = () => {
|
||||||
|
|
||||||
return messages.filter((msg) => {
|
return messages.filter((msg) => {
|
||||||
if (msg.type === "system" && msg.subtype === "init") return false;
|
if (msg.type === "system" && msg.subtype === "init") return false;
|
||||||
if (msg.type !== "user" && msg.type !== "assistant" && msg.type !== "result") return false;
|
if (
|
||||||
|
msg.type !== "user" &&
|
||||||
|
msg.type !== "assistant" &&
|
||||||
|
msg.type !== "result"
|
||||||
|
)
|
||||||
|
return false;
|
||||||
if (msg.type === "user" && msg.message?.content) {
|
if (msg.type === "user" && msg.message?.content) {
|
||||||
if (Array.isArray(msg.message.content)) {
|
if (Array.isArray(msg.message.content)) {
|
||||||
const hasOnlyToolResults = msg.message.content.every(
|
const hasOnlyToolResults = msg.message.content.every(
|
||||||
(b: any) => b.type === "tool_result"
|
(b: any) => b.type === "tool_result",
|
||||||
);
|
);
|
||||||
if (hasOnlyToolResults) return false;
|
if (hasOnlyToolResults) return false;
|
||||||
}
|
}
|
||||||
|
|
@ -100,7 +184,7 @@ export const ChatMessages: FC = () => {
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [displayMessages]);
|
}, [displayMessages, pendingGuidance]);
|
||||||
|
|
||||||
// Reset auto-scroll when streaming stops
|
// Reset auto-scroll when streaming stops
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -113,7 +197,8 @@ export const ChatMessages: FC = () => {
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
if (!viewportRef.current) return;
|
if (!viewportRef.current) return;
|
||||||
const el = viewportRef.current;
|
const el = viewportRef.current;
|
||||||
const isAtBottom = Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 50;
|
const isAtBottom =
|
||||||
|
Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 50;
|
||||||
if (!isAtBottom) {
|
if (!isAtBottom) {
|
||||||
userHasScrolledRef.current = true;
|
userHasScrolledRef.current = true;
|
||||||
shouldAutoScrollRef.current = false;
|
shouldAutoScrollRef.current = false;
|
||||||
|
|
@ -127,23 +212,36 @@ export const ChatMessages: FC = () => {
|
||||||
<div
|
<div
|
||||||
ref={viewportRef}
|
ref={viewportRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
className="absolute inset-0 overflow-y-auto scroll-smooth px-4 py-2"
|
className="absolute inset-0 overflow-y-auto scroll-smooth px-4 pt-4"
|
||||||
>
|
>
|
||||||
{displayMessages.length === 0 && !isStreaming && (
|
{displayMessages.length === 0 &&
|
||||||
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
|
pendingGuidance.length === 0 &&
|
||||||
Ask Claude about your LaTeX document...
|
!isStreaming && (
|
||||||
|
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
|
||||||
|
Ask Claude about your LaTeX document...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayMessages.map((msg, idx) => (
|
||||||
|
<div key={idx} className={cn("mx-auto w-full", THREAD_MAX_WIDTH)}>
|
||||||
|
<MessageBubble message={msg} toolResultMap={toolResultMap} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isStreaming && (
|
||||||
|
<div className={cn("mx-auto w-full px-2", THREAD_MAX_WIDTH)}>
|
||||||
|
<StreamingIndicator startedAt={streamingStartedAt} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{displayMessages.map((msg, idx) => (
|
{pendingGuidance.map((guidance) => (
|
||||||
<MessageBubble
|
<div
|
||||||
key={idx}
|
key={guidance.id}
|
||||||
message={msg}
|
className={cn("mx-auto w-full", THREAD_MAX_WIDTH)}
|
||||||
toolResultMap={toolResultMap}
|
>
|
||||||
/>
|
<PendingGuidanceMessage guidance={guidance} />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{isStreaming && <StreamingIndicator />}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -181,23 +279,28 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
||||||
|
|
||||||
if (!textContent) return null;
|
if (!textContent) return null;
|
||||||
|
|
||||||
// Parse leading @file:line:col or ~@file:line context reference
|
const firstLineMatch = textContent.match(/^([^\n]+)\n([\s\S]*)$/);
|
||||||
const contextMatch = textContent.match(/^(~?@[^\n]+)\n([\s\S]*)$/);
|
const firstLine = firstLineMatch?.[1]?.trim() ?? "";
|
||||||
const contextLabel = contextMatch?.[1] ?? null;
|
const hasContextLabel =
|
||||||
const bodyText = contextMatch ? contextMatch[2] : textContent;
|
firstLine.startsWith("@") ||
|
||||||
|
firstLine.startsWith("~@") ||
|
||||||
|
/^Pasted image(?: \d+)?(?:, Pasted image(?: \d+)?)*$/.test(firstLine);
|
||||||
|
const contextLabel = hasContextLabel ? firstLine : null;
|
||||||
|
const bodyText =
|
||||||
|
hasContextLabel && firstLineMatch ? firstLineMatch[2] : textContent;
|
||||||
|
|
||||||
// Parse error block patterns for styled rendering:
|
// Parse error block patterns for styled rendering:
|
||||||
// Lint single: "[Lint error in FILE:LINE]\n[Error: MSG]\n\nPrompt"
|
// Lint single: "[Lint error in FILE:LINE]\n[Error: MSG]\n\nPrompt"
|
||||||
// Lint multi: "[Lint errors in FILE]\n- FILE:LINE — MSG\n...\n\nPrompt"
|
// Lint multi: "[Lint errors in FILE]\n- FILE:LINE — MSG\n...\n\nPrompt"
|
||||||
// Compile: "[Compilation errors]\n- error1\n- error2\n...\n\nPrompt"
|
// Compile: "[Compilation errors]\n- error1\n- error2\n...\n\nPrompt"
|
||||||
const lintSingleMatch = bodyText.match(
|
const lintSingleMatch = bodyText.match(
|
||||||
/^\[Lint error in ([^\]]+)\]\n\[Error: ([^\]]+)\]\n\n([\s\S]*)$/
|
/^\[Lint error in ([^\]]+)\]\n\[Error: ([^\]]+)\]\n\n([\s\S]*)$/,
|
||||||
);
|
);
|
||||||
const lintMultiMatch = bodyText.match(
|
const lintMultiMatch = bodyText.match(
|
||||||
/^\[Lint errors in ([^\]]+)\]\n((?:- .+\n?)+)\n([\s\S]*)$/
|
/^\[Lint errors in ([^\]]+)\]\n((?:- .+\n?)+)\n([\s\S]*)$/,
|
||||||
);
|
);
|
||||||
const compileErrorMatch = bodyText.match(
|
const compileErrorMatch = bodyText.match(
|
||||||
/^\[Compilation errors\]\n((?:- .+\n?)+)\n([\s\S]*)$/
|
/^\[Compilation errors\]\n((?:- .+\n?)+)\n([\s\S]*)$/,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Shared error block renderer
|
// Shared error block renderer
|
||||||
|
|
@ -206,23 +309,34 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
||||||
errors: { message: string; location?: string }[],
|
errors: { message: string; location?: string }[],
|
||||||
prompt: string,
|
prompt: string,
|
||||||
) => (
|
) => (
|
||||||
<div className="flex w-full flex-col items-end py-1.5">
|
<div className="fade-in slide-in-from-bottom-1 grid w-full animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2">
|
||||||
<div className="max-w-[85%] rounded-xl bg-muted px-3 py-2 text-foreground text-sm">
|
<div className="relative col-start-2 min-w-0">
|
||||||
<div className="mb-2 rounded-lg border border-red-500/20 bg-red-500/10 px-2.5 py-2">
|
<div className="wrap-break-word rounded-xl bg-muted px-4 py-2 text-foreground text-sm empty:hidden">
|
||||||
<div className="mb-1.5 text-xs font-medium text-red-400">{title}</div>
|
<div className="mb-2 rounded-lg border border-red-500/20 bg-red-500/10 px-2.5 py-2">
|
||||||
<div className="space-y-1">
|
<div className="mb-1.5 font-medium text-red-400 text-xs">
|
||||||
{errors.map((e, i) => (
|
{title}
|
||||||
<div key={i} className="flex items-start gap-1.5">
|
</div>
|
||||||
<AlertCircleIcon className="mt-0.5 size-3 shrink-0 text-red-400/70" />
|
<div className="space-y-1">
|
||||||
<span className="flex-1 text-xs text-foreground/80">{e.message}</span>
|
{errors.map((e, i) => (
|
||||||
{e.location && (
|
<div key={i} className="flex items-start gap-1.5">
|
||||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">{e.location}</span>
|
<AlertCircleIcon className="mt-0.5 size-3 shrink-0 text-red-400/70" />
|
||||||
)}
|
<span className="flex-1 text-foreground/80 text-xs">
|
||||||
</div>
|
{e.message}
|
||||||
))}
|
</span>
|
||||||
|
{e.location && (
|
||||||
|
<span className="shrink-0 font-mono text-muted-foreground text-xs">
|
||||||
|
{e.location}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-muted-foreground">{prompt}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-muted-foreground">{prompt}</span>
|
</div>
|
||||||
|
<div className="col-span-full col-start-1 row-start-2 -mr-1 flex justify-end">
|
||||||
|
<MessageActions text={bodyText} align="right" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -238,18 +352,26 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
||||||
|
|
||||||
if (lintMultiMatch) {
|
if (lintMultiMatch) {
|
||||||
const [, fileName, errorLines, prompt] = lintMultiMatch;
|
const [, fileName, errorLines, prompt] = lintMultiMatch;
|
||||||
const errors = errorLines.trim().split("\n").map((line) => {
|
const errors = errorLines
|
||||||
const m = line.match(/^- (.+?):(\d+) — (.+)$/);
|
.trim()
|
||||||
return m ? { message: m[3], location: `${m[1]}:${m[2]}` } : { message: line.replace(/^- /, "") };
|
.split("\n")
|
||||||
});
|
.map((line) => {
|
||||||
|
const m = line.match(/^- (.+?):(\d+) — (.+)$/);
|
||||||
|
return m
|
||||||
|
? { message: m[3], location: `${m[1]}:${m[2]}` }
|
||||||
|
: { message: line.replace(/^- /, "") };
|
||||||
|
});
|
||||||
return renderErrorBlock(`Lint Errors — ${fileName}`, errors, prompt);
|
return renderErrorBlock(`Lint Errors — ${fileName}`, errors, prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (compileErrorMatch) {
|
if (compileErrorMatch) {
|
||||||
const [, errorLines, prompt] = compileErrorMatch;
|
const [, errorLines, prompt] = compileErrorMatch;
|
||||||
const errors = errorLines.trim().split("\n").map((line) => ({
|
const errors = errorLines
|
||||||
message: line.replace(/^- /, ""),
|
.trim()
|
||||||
}));
|
.split("\n")
|
||||||
|
.map((line) => ({
|
||||||
|
message: line.replace(/^- /, ""),
|
||||||
|
}));
|
||||||
return renderErrorBlock(
|
return renderErrorBlock(
|
||||||
`Compilation ${errors.length === 1 ? "Error" : "Errors"}`,
|
`Compilation ${errors.length === 1 ? "Error" : "Errors"}`,
|
||||||
errors,
|
errors,
|
||||||
|
|
@ -258,18 +380,23 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col items-end py-1.5">
|
<div className="fade-in slide-in-from-bottom-1 grid w-full animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2">
|
||||||
<div className="max-w-[85%] rounded-xl bg-muted px-3 py-1.5 text-foreground text-sm">
|
<div className="relative col-start-2 min-w-0">
|
||||||
{contextLabel && (
|
<div className="wrap-break-word rounded-xl bg-muted px-4 py-2 text-foreground text-sm empty:hidden">
|
||||||
<span className="mb-1 inline-flex items-center rounded-md bg-background/60 px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
|
{contextLabel && (
|
||||||
{contextLabel}
|
<span className="mb-1 inline-flex items-center rounded-md bg-background/60 px-1.5 py-0.5 font-mono text-muted-foreground text-xs">
|
||||||
</span>
|
{contextLabel}
|
||||||
)}
|
</span>
|
||||||
{contextLabel && bodyText && <br />}
|
)}
|
||||||
<MarkdownRenderer
|
{contextLabel && bodyText && <br />}
|
||||||
content={bodyText}
|
<MarkdownRenderer
|
||||||
className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
|
content={bodyText}
|
||||||
/>
|
className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-full col-start-1 row-start-2 -mr-1 flex justify-end">
|
||||||
|
<MessageActions text={textContent} align="right" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -277,6 +404,40 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
||||||
|
|
||||||
// ─── Assistant Message ───
|
// ─── Assistant Message ───
|
||||||
|
|
||||||
|
const PendingGuidanceMessage: FC<{ guidance: QueuedGuidance }> = ({
|
||||||
|
guidance,
|
||||||
|
}) => {
|
||||||
|
const contextLabel = guidance.contextOverride?.label ?? null;
|
||||||
|
const copyText = contextLabel
|
||||||
|
? `${contextLabel}\n${guidance.prompt}`
|
||||||
|
: guidance.prompt;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fade-in slide-in-from-bottom-1 grid w-full animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2">
|
||||||
|
<div className="relative col-start-2 min-w-0">
|
||||||
|
<div className="wrap-break-word rounded-xl bg-muted px-4 py-2 text-foreground text-sm empty:hidden">
|
||||||
|
{contextLabel && (
|
||||||
|
<span className="mb-1 inline-flex items-center rounded-md bg-background/60 px-1.5 py-0.5 font-mono text-muted-foreground text-xs">
|
||||||
|
{contextLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{contextLabel && guidance.prompt && <br />}
|
||||||
|
<div className="flex min-w-0 items-start gap-2">
|
||||||
|
<CornerDownRightIcon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/70" />
|
||||||
|
<MarkdownRenderer
|
||||||
|
content={guidance.prompt}
|
||||||
|
className="prose prose-sm dark:prose-invert min-w-0 max-w-none flex-1 break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-full col-start-1 row-start-2 -mr-1 flex justify-end">
|
||||||
|
<MessageActions text={copyText} align="right" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const AssistantMessage: FC<{
|
const AssistantMessage: FC<{
|
||||||
message: ClaudeStreamMessage;
|
message: ClaudeStreamMessage;
|
||||||
toolResultMap: Map<string, ContentBlock>;
|
toolResultMap: Map<string, ContentBlock>;
|
||||||
|
|
@ -288,24 +449,20 @@ const AssistantMessage: FC<{
|
||||||
(block) =>
|
(block) =>
|
||||||
(block.type === "text" && block.text) ||
|
(block.type === "text" && block.text) ||
|
||||||
(block.type === "thinking" && block.thinking) ||
|
(block.type === "thinking" && block.thinking) ||
|
||||||
(block.type === "tool_use" && block.id)
|
(block.type === "tool_use" && block.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hasRenderableContent) return null;
|
if (!hasRenderableContent) return null;
|
||||||
|
|
||||||
|
const copyText = content
|
||||||
|
.filter((block) => block.type === "text" && block.text)
|
||||||
|
.map((block) => block.text)
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full py-1.5">
|
<div className="fade-in slide-in-from-bottom-1 relative mx-auto w-full animate-in py-3 duration-150">
|
||||||
<div className="px-1 text-foreground text-sm leading-relaxed">
|
<div className="wrap-break-word px-2 text-foreground text-sm leading-relaxed">
|
||||||
{content.map((block, idx) => {
|
{content.map((block, idx) => {
|
||||||
if (block.type === "thinking" && block.thinking) {
|
|
||||||
return (
|
|
||||||
<ThinkingWidget
|
|
||||||
key={idx}
|
|
||||||
thinking={block.thinking}
|
|
||||||
signature={block.signature}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (block.type === "text" && block.text) {
|
if (block.type === "text" && block.text) {
|
||||||
return (
|
return (
|
||||||
<MarkdownRenderer
|
<MarkdownRenderer
|
||||||
|
|
@ -317,17 +474,23 @@ const AssistantMessage: FC<{
|
||||||
}
|
}
|
||||||
if (block.type === "tool_use" && block.id) {
|
if (block.type === "tool_use" && block.id) {
|
||||||
const result = toolResultMap.get(block.id);
|
const result = toolResultMap.get(block.id);
|
||||||
|
return <ToolWidget key={idx} toolUse={block} toolResult={result} />;
|
||||||
|
}
|
||||||
|
if (block.type === "thinking" && block.thinking) {
|
||||||
return (
|
return (
|
||||||
<ToolWidget
|
<ThinkingWidget
|
||||||
key={idx}
|
key={idx}
|
||||||
toolUse={block}
|
thinking={block.thinking}
|
||||||
toolResult={result}
|
signature={block.signature}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="-mb-7.5 ml-2 flex min-h-7.5 items-center pt-1.5">
|
||||||
|
<MessageActions text={copyText} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -341,8 +504,8 @@ const ResultMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
||||||
if (!resultText) return null;
|
if (!resultText) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full py-1.5">
|
<div className="fade-in slide-in-from-bottom-1 relative mx-auto w-full animate-in py-3 duration-150">
|
||||||
<div className="px-1 text-foreground text-sm leading-relaxed">
|
<div className="wrap-break-word px-2 text-foreground text-sm leading-relaxed">
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive text-sm">
|
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive text-sm">
|
||||||
{resultText}
|
{resultText}
|
||||||
|
|
@ -354,6 +517,9 @@ const ResultMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="-mb-7.5 ml-2 flex min-h-7.5 items-center pt-1.5">
|
||||||
|
<MessageActions text={resultText} />
|
||||||
|
</div>
|
||||||
{message.cost_usd != null && (
|
{message.cost_usd != null && (
|
||||||
<div className="mt-1 px-1 text-right text-muted-foreground text-xs">
|
<div className="mt-1 px-1 text-right text-muted-foreground text-xs">
|
||||||
Cost: ${message.cost_usd.toFixed(4)}
|
Cost: ${message.cost_usd.toFixed(4)}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,14 @@ export function ChatTabBar() {
|
||||||
|
|
||||||
// Scroll active tab into view when it changes
|
// Scroll active tab into view when it changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current?.querySelector(`[data-tab-id="${activeTabId}"]`);
|
const el = scrollRef.current?.querySelector(
|
||||||
el?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
|
`[data-tab-id="${activeTabId}"]`,
|
||||||
|
);
|
||||||
|
el?.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "nearest",
|
||||||
|
inline: "nearest",
|
||||||
|
});
|
||||||
}, [activeTabId]);
|
}, [activeTabId]);
|
||||||
|
|
||||||
// Keyboard shortcuts: Ctrl+Tab / Ctrl+Shift+Tab to switch tabs, Ctrl+T new, Ctrl+W close
|
// Keyboard shortcuts: Ctrl+Tab / Ctrl+Shift+Tab to switch tabs, Ctrl+T new, Ctrl+W close
|
||||||
|
|
@ -72,10 +78,10 @@ export function ChatTabBar() {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center border-b border-border">
|
<div className="flex items-center border-border border-b">
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="flex min-w-0 flex-1 items-center overflow-x-auto scrollbar-none"
|
className="scrollbar-none flex min-w-0 flex-1 items-center overflow-x-auto"
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<TabButton
|
<TabButton
|
||||||
|
|
@ -89,11 +95,11 @@ export function ChatTabBar() {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center">
|
<div className="flex shrink-0 items-center gap-0.5 pr-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
className="flex size-7 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
aria-label="New tab"
|
aria-label="New tab"
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-3.5" />
|
<PlusIcon className="size-3.5" />
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,16 @@ import { ChatMessages } from "./chat-messages";
|
||||||
import { ChatComposer } from "./chat-composer";
|
import { ChatComposer } from "./chat-composer";
|
||||||
import { ChatTabBar } from "./chat-tab-bar";
|
import { ChatTabBar } from "./chat-tab-bar";
|
||||||
|
|
||||||
const MIN_HEIGHT = 150;
|
const MIN_HEIGHT = 260;
|
||||||
const DEFAULT_HEIGHT = 360;
|
const DEFAULT_HEIGHT = 360;
|
||||||
|
|
||||||
export function ClaudeChatDrawer() {
|
export function ClaudeChatDrawer() {
|
||||||
// Initialize event listeners for Claude streaming
|
// Initialize event listeners for Claude streaming
|
||||||
useClaudeEvents();
|
useClaudeEvents();
|
||||||
|
|
||||||
const anyStreaming = useClaudeChatStore((s) => s.tabs.some((t) => t.isStreaming));
|
const anyStreaming = useClaudeChatStore((s) =>
|
||||||
|
s.tabs.some((t) => t.isStreaming),
|
||||||
|
);
|
||||||
const error = useClaudeChatStore((s) => s.error);
|
const error = useClaudeChatStore((s) => s.error);
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
@ -41,50 +43,58 @@ export function ClaudeChatDrawer() {
|
||||||
if (shouldOpen && !isOpen) {
|
if (shouldOpen && !isOpen) {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
const parent = containerRef.current?.parentElement;
|
const parent = containerRef.current?.parentElement;
|
||||||
const maxHeight = parent ? parent.clientHeight * 0.5 : 400;
|
const maxHeight = parent
|
||||||
setHeight(maxHeight);
|
? Math.max(MIN_HEIGHT, parent.clientHeight * 0.5)
|
||||||
heightRef.current = maxHeight;
|
: 400;
|
||||||
|
const nextHeight = Math.max(maxHeight, MIN_HEIGHT);
|
||||||
|
setHeight(nextHeight);
|
||||||
|
heightRef.current = nextHeight;
|
||||||
if (panelRef.current) {
|
if (panelRef.current) {
|
||||||
panelRef.current.style.height = `${maxHeight}px`;
|
panelRef.current.style.height = `${nextHeight}px`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [anyStreaming, isOpen, pendingAttachments]);
|
}, [anyStreaming, isOpen, pendingAttachments]);
|
||||||
|
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
const handleMouseDown = useCallback(
|
||||||
if (isExpanded) return;
|
(e: React.MouseEvent) => {
|
||||||
|
if (isExpanded) return;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
hasDraggedRef.current = false;
|
hasDraggedRef.current = false;
|
||||||
|
|
||||||
const startY = e.clientY;
|
const startY = e.clientY;
|
||||||
const startHeight = heightRef.current;
|
const startHeight = heightRef.current;
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
hasDraggedRef.current = true;
|
hasDraggedRef.current = true;
|
||||||
const parent = containerRef.current?.parentElement;
|
const parent = containerRef.current?.parentElement;
|
||||||
const maxHeight = parent ? parent.clientHeight * 0.5 : 400;
|
const maxHeight = parent
|
||||||
const delta = startY - e.clientY;
|
? Math.max(MIN_HEIGHT, parent.clientHeight * 0.5)
|
||||||
const newHeight = Math.min(
|
: 400;
|
||||||
Math.max(startHeight + delta, MIN_HEIGHT),
|
const delta = startY - e.clientY;
|
||||||
maxHeight
|
const newHeight = Math.min(
|
||||||
);
|
Math.max(startHeight + delta, MIN_HEIGHT),
|
||||||
heightRef.current = newHeight;
|
maxHeight,
|
||||||
if (panelRef.current) {
|
);
|
||||||
panelRef.current.style.height = `${newHeight}px`;
|
heightRef.current = newHeight;
|
||||||
}
|
if (panelRef.current) {
|
||||||
};
|
panelRef.current.style.height = `${newHeight}px`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
setHeight(heightRef.current);
|
setHeight(heightRef.current);
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
}, [isExpanded]);
|
},
|
||||||
|
[isExpanded],
|
||||||
|
);
|
||||||
|
|
||||||
// Compute expanded dimensions from parent
|
// Compute expanded dimensions from parent
|
||||||
const getExpandedDimensions = useCallback(() => {
|
const getExpandedDimensions = useCallback(() => {
|
||||||
|
|
@ -103,7 +113,12 @@ export function ClaudeChatDrawer() {
|
||||||
const dims = getExpandedDimensions();
|
const dims = getExpandedDimensions();
|
||||||
return { height: dims.height, maxWidth: dims.width, borderRadius: 0 };
|
return { height: dims.height, maxWidth: dims.width, borderRadius: 0 };
|
||||||
}
|
}
|
||||||
return { height, maxWidth: 672, borderRadius: 24 };
|
return {
|
||||||
|
height: Math.max(height, MIN_HEIGHT),
|
||||||
|
minHeight: MIN_HEIGHT,
|
||||||
|
maxWidth: 672,
|
||||||
|
borderRadius: 24,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -111,7 +126,7 @@ export function ClaudeChatDrawer() {
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none absolute inset-0 z-10 flex items-end justify-center transition-[padding] duration-300 ease-out",
|
"pointer-events-none absolute inset-0 z-10 flex items-end justify-center transition-[padding] duration-300 ease-out",
|
||||||
isExpanded ? "p-0" : "px-4 pb-6 pt-4"
|
isExpanded ? "p-0" : "px-4 pt-4 pb-6",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Floating toggle button */}
|
{/* Floating toggle button */}
|
||||||
|
|
@ -122,7 +137,7 @@ export function ClaudeChatDrawer() {
|
||||||
"pointer-events-auto absolute right-4 bottom-6 flex size-12 items-center justify-center rounded-full border border-border bg-background shadow-lg transition-all duration-300 ease-out hover:scale-105 hover:shadow-xl",
|
"pointer-events-auto absolute right-4 bottom-6 flex size-12 items-center justify-center rounded-full border border-border bg-background shadow-lg transition-all duration-300 ease-out hover:scale-105 hover:shadow-xl",
|
||||||
isOpen
|
isOpen
|
||||||
? "pointer-events-none scale-50 opacity-0"
|
? "pointer-events-none scale-50 opacity-0"
|
||||||
: "scale-100 opacity-100"
|
: "scale-100 opacity-100",
|
||||||
)}
|
)}
|
||||||
aria-label="Open AI Assistant"
|
aria-label="Open AI Assistant"
|
||||||
>
|
>
|
||||||
|
|
@ -134,18 +149,20 @@ export function ClaudeChatDrawer() {
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-auto flex w-full flex-col overflow-hidden border bg-background transition-[height,max-width,border-radius,border-color,box-shadow,opacity,transform] duration-300 ease-out",
|
"pointer-events-auto flex w-full flex-col overflow-hidden border bg-background transition-[height,max-width,border-radius,border-color,box-shadow,opacity,transform] duration-300 ease-out",
|
||||||
isExpanded ? "border-transparent shadow-none" : "border-border shadow-2xl",
|
isExpanded
|
||||||
|
? "border-transparent shadow-none"
|
||||||
|
: "border-border shadow-2xl",
|
||||||
isOpen
|
isOpen
|
||||||
? "scale-100 opacity-100"
|
? "scale-100 opacity-100"
|
||||||
: "pointer-events-none origin-bottom scale-95 opacity-0",
|
: "pointer-events-none origin-bottom scale-95 opacity-0",
|
||||||
isDragging && "!transition-none"
|
isDragging && "!transition-none",
|
||||||
)}
|
)}
|
||||||
style={panelStyle()}
|
style={panelStyle()}
|
||||||
>
|
>
|
||||||
{/* Header with drag handle, tab bar, and session selector */}
|
{/* Header with drag handle, tab bar, and session selector */}
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-start border-b border-border px-2 py-1">
|
<div className="flex items-center justify-start border-border border-b px-2 py-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsExpanded(false)}
|
onClick={() => setIsExpanded(false)}
|
||||||
|
|
@ -189,7 +206,7 @@ export function ClaudeChatDrawer() {
|
||||||
|
|
||||||
{/* Error banner */}
|
{/* Error banner */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mx-3 mb-1 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-destructive text-xs">
|
<div className="mx-3 mt-2 mb-1 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-destructive text-xs">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -17,19 +17,48 @@ import { invoke } from "@tauri-apps/api/core";
|
||||||
import "katex/dist/katex.min.css";
|
import "katex/dist/katex.min.css";
|
||||||
|
|
||||||
import { useDocumentStore } from "@/stores/document-store";
|
import { useDocumentStore } from "@/stores/document-store";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// ─── Shell Detection ───
|
// ─── Shell Detection ───
|
||||||
|
|
||||||
const SHELL_LANGUAGES = new Set([
|
const SHELL_LANGUAGES = new Set([
|
||||||
"bash", "sh", "shell", "zsh", "fish", "terminal", "console",
|
"bash",
|
||||||
|
"sh",
|
||||||
|
"shell",
|
||||||
|
"zsh",
|
||||||
|
"fish",
|
||||||
|
"terminal",
|
||||||
|
"console",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function looksLikeShellCommand(code: string): boolean {
|
function looksLikeShellCommand(code: string): boolean {
|
||||||
const firstLine = code.trim().split("\n")[0].replace(/^[\$#]\s*/, "").trim();
|
const firstLine = code
|
||||||
|
.trim()
|
||||||
|
.split("\n")[0]
|
||||||
|
.replace(/^[$#]\s*/, "")
|
||||||
|
.trim();
|
||||||
const prefixes = [
|
const prefixes = [
|
||||||
"wget", "curl", "tlmgr", "apt", "brew", "npm", "pip", "sudo",
|
"wget",
|
||||||
"mkdir", "cd ", "cp ", "mv ", "rm ", "git ", "make", "tar ", "unzip",
|
"curl",
|
||||||
"latexmk", "pdflatex", "xelatex", "bibtex",
|
"tlmgr",
|
||||||
|
"apt",
|
||||||
|
"brew",
|
||||||
|
"npm",
|
||||||
|
"pip",
|
||||||
|
"sudo",
|
||||||
|
"mkdir",
|
||||||
|
"cd ",
|
||||||
|
"cp ",
|
||||||
|
"mv ",
|
||||||
|
"rm ",
|
||||||
|
"git ",
|
||||||
|
"make",
|
||||||
|
"tar ",
|
||||||
|
"unzip",
|
||||||
|
"latexmk",
|
||||||
|
"pdflatex",
|
||||||
|
"xelatex",
|
||||||
|
"bibtex",
|
||||||
];
|
];
|
||||||
return prefixes.some((p) => firstLine.startsWith(p));
|
return prefixes.some((p) => firstLine.startsWith(p));
|
||||||
}
|
}
|
||||||
|
|
@ -55,21 +84,73 @@ export const MarkdownRenderer: FC<MarkdownRendererProps> = ({
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm, remarkMath]}
|
remarkPlugins={[remarkGfm, remarkMath]}
|
||||||
rehypePlugins={[rehypeKatex]}
|
rehypePlugins={[rehypeKatex]}
|
||||||
className={className ?? "prose prose-sm dark:prose-invert max-w-none"}
|
className={cn(
|
||||||
|
"min-w-0 max-w-full break-words [overflow-wrap:anywhere] [&_*]:max-w-full",
|
||||||
|
className ?? "prose prose-sm dark:prose-invert max-w-none",
|
||||||
|
)}
|
||||||
components={{
|
components={{
|
||||||
pre({ children }) {
|
pre({ children }) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
},
|
},
|
||||||
|
table({ children, node, ...props }) {
|
||||||
|
return (
|
||||||
|
<div className="my-3 w-full overflow-x-auto rounded-lg border border-border">
|
||||||
|
<table
|
||||||
|
className="m-0 w-full border-collapse text-left text-sm"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
thead({ children, node, ...props }) {
|
||||||
|
return (
|
||||||
|
<thead className="bg-muted/70" {...props}>
|
||||||
|
{children}
|
||||||
|
</thead>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
th({ children, node, ...props }) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
className="border-border border-r border-b px-3 py-2 font-medium text-foreground last:border-r-0"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
td({ children, node, ...props }) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
className="border-border border-t border-r px-3 py-2 align-top text-foreground last:border-r-0"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
hr({ node, ...props }) {
|
||||||
|
return <hr className="my-5 border-border border-t" {...props} />;
|
||||||
|
},
|
||||||
code({ className: codeClassName, children, node, ...props }) {
|
code({ className: codeClassName, children, node, ...props }) {
|
||||||
const match = /language-(\w+)/.exec(codeClassName || "");
|
const match = /language-(\w+)/.exec(codeClassName || "");
|
||||||
const language = match?.[1];
|
const language = match?.[1];
|
||||||
const code = String(children).replace(/\n$/, "");
|
const code = String(children).replace(/\n$/, "");
|
||||||
const isBlock = node?.position &&
|
const isBlock =
|
||||||
|
node?.position &&
|
||||||
node.position.start.line !== node.position.end.line;
|
node.position.start.line !== node.position.end.line;
|
||||||
|
|
||||||
if (!match && !isBlock) {
|
if (!match && !isBlock) {
|
||||||
return (
|
return (
|
||||||
<code className={codeClassName} {...props}>
|
<code
|
||||||
|
className={cn(
|
||||||
|
"break-words [overflow-wrap:anywhere]",
|
||||||
|
codeClassName,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
|
|
@ -138,9 +219,12 @@ const CodeBlock: FC<{ language: string; code: string }> = ({
|
||||||
stderr: result.stderr,
|
stderr: result.stderr,
|
||||||
});
|
});
|
||||||
// Refresh file tree to pick up any new/deleted files
|
// Refresh file tree to pick up any new/deleted files
|
||||||
useDocumentStore.getState().refreshFiles().catch((err) => {
|
useDocumentStore
|
||||||
console.error("Failed to refresh files:", err);
|
.getState()
|
||||||
});
|
.refreshFiles()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Failed to refresh files:", err);
|
||||||
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setRunState({ status: "error", message: err?.message || String(err) });
|
setRunState({ status: "error", message: err?.message || String(err) });
|
||||||
}
|
}
|
||||||
|
|
@ -186,7 +270,10 @@ const CodeBlock: FC<{ language: string; code: string }> = ({
|
||||||
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
|
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
|
||||||
<AlertTriangleIcon className="size-3.5 text-yellow-500" />
|
<AlertTriangleIcon className="size-3.5 text-yellow-500" />
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
Run in <code className="rounded bg-muted px-1 text-xs">{projectRoot?.split("/").pop()}/</code>
|
Run in{" "}
|
||||||
|
<code className="rounded bg-muted px-1 text-xs">
|
||||||
|
{projectRoot?.split(/[/\\]/).pop()}/
|
||||||
|
</code>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
@ -213,7 +300,9 @@ const CodeBlock: FC<{ language: string; code: string }> = ({
|
||||||
{runState.status === "running" && (
|
{runState.status === "running" && (
|
||||||
<div className="mt-1 flex items-center gap-2 rounded-lg border border-border bg-[#1e1e2e] px-3 py-2 text-sm">
|
<div className="mt-1 flex items-center gap-2 rounded-lg border border-border bg-[#1e1e2e] px-3 py-2 text-sm">
|
||||||
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
|
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
|
||||||
<span className="font-mono text-muted-foreground text-xs">Running...</span>
|
<span className="font-mono text-muted-foreground text-xs">
|
||||||
|
Running...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -246,9 +335,9 @@ const CommandOutput: FC<{
|
||||||
}> = ({ exitCode, stdout, stderr }) => {
|
}> = ({ exitCode, stdout, stderr }) => {
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
const success = exitCode === 0;
|
const success = exitCode === 0;
|
||||||
const output = (stdout + (stderr ? "\n" + stderr : "")).trim();
|
const output = (stdout + (stderr ? `\n${stderr}` : "")).trim();
|
||||||
const truncated =
|
const truncated =
|
||||||
output.length > 2000 ? output.slice(0, 2000) + "\n..." : output;
|
output.length > 2000 ? `${output.slice(0, 2000)}\n...` : output;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-1 rounded-lg border border-border bg-[#1e1e2e] text-sm">
|
<div className="mt-1 rounded-lg border border-border bg-[#1e1e2e] text-sm">
|
||||||
|
|
@ -276,8 +365,8 @@ const CommandOutput: FC<{
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{expanded && truncated && (
|
{expanded && truncated && (
|
||||||
<div className="max-h-40 overflow-auto border-t border-border/50 px-3 py-2">
|
<div className="max-h-40 overflow-auto border-border/50 border-t px-3 py-2">
|
||||||
<pre className="whitespace-pre-wrap font-mono text-xs text-gray-300">
|
<pre className="whitespace-pre-wrap font-mono text-gray-300 text-xs">
|
||||||
{truncated}
|
{truncated}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { type FC } from "react";
|
import { type FC, useEffect, useRef, useState } from "react";
|
||||||
import { Check, X } from "lucide-react";
|
import { Check, X } from "lucide-react";
|
||||||
import { type ProposedChange } from "@/stores/proposed-changes-store";
|
import { type ProposedChange } from "@/stores/proposed-changes-store";
|
||||||
|
|
||||||
|
|
@ -17,42 +17,79 @@ export const ProposedChangesPanel: FC<ProposedChangesPanelProps> = ({
|
||||||
onKeep,
|
onKeep,
|
||||||
onUndo,
|
onUndo,
|
||||||
}) => {
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [containerWidth, setContainerWidth] = useState(0);
|
||||||
const oldLines = change.oldContent.split("\n").length;
|
const oldLines = change.oldContent.split("\n").length;
|
||||||
const newLines = change.newContent.split("\n").length;
|
const newLines = change.newContent.split("\n").length;
|
||||||
const added = Math.max(0, newLines - oldLines);
|
const added = Math.max(0, newLines - oldLines);
|
||||||
const removed = Math.max(0, oldLines - newLines);
|
const removed = Math.max(0, oldLines - newLines);
|
||||||
|
const compactTitle = containerWidth > 0 && containerWidth < 680;
|
||||||
|
const hideToolName = containerWidth > 0 && containerWidth < 920;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = containerRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const updateWidth = () => setContainerWidth(element.clientWidth);
|
||||||
|
updateWidth();
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(updateWidth);
|
||||||
|
observer.observe(element);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between border-border border-t bg-muted/50 px-3 py-1.5">
|
<div
|
||||||
<div className="flex items-center gap-2 text-sm">
|
ref={containerRef}
|
||||||
<span className="font-medium text-foreground">Proposed Changes</span>
|
className="grid h-9 min-w-0 shrink-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 overflow-hidden border-border border-t bg-muted/50 px-3"
|
||||||
|
>
|
||||||
|
<div className="flex shrink-0 items-center gap-2 text-sm">
|
||||||
|
<span className="whitespace-nowrap font-medium text-foreground">
|
||||||
|
{compactTitle ? "Changes" : "Proposed Changes"}
|
||||||
|
</span>
|
||||||
{totalChanges > 1 && (
|
{totalChanges > 1 && (
|
||||||
<span className="rounded bg-violet-500/15 px-1.5 py-0.5 text-xs font-medium text-violet-600 dark:text-violet-400">
|
<span className="shrink-0 whitespace-nowrap rounded bg-violet-500/15 px-1.5 py-0.5 font-medium text-violet-600 text-xs dark:text-violet-400">
|
||||||
{changeIndex + 1}/{totalChanges} files
|
{changeIndex + 1}/{totalChanges} files
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-muted-foreground">{change.filePath}</span>
|
|
||||||
<span className="text-muted-foreground">{change.toolName}</span>
|
|
||||||
{added > 0 && <span className="text-green-400">+{added}</span>}
|
|
||||||
{removed > 0 && <span className="text-red-400">-{removed}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex min-w-0 items-center gap-2 overflow-hidden text-sm">
|
||||||
|
<span className="min-w-0 truncate text-muted-foreground">
|
||||||
|
{change.filePath}
|
||||||
|
</span>
|
||||||
|
{!hideToolName && (
|
||||||
|
<span className="shrink-0 text-muted-foreground">
|
||||||
|
{change.toolName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{added > 0 && <span className="shrink-0 text-green-400">+{added}</span>}
|
||||||
|
{removed > 0 && (
|
||||||
|
<span className="shrink-0 text-red-400">-{removed}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onKeep}
|
onClick={onKeep}
|
||||||
className="flex items-center gap-1 rounded-md bg-green-600/20 px-2.5 py-1 text-green-400 text-xs hover:bg-green-600/30 transition-colors"
|
className="flex items-center justify-center gap-1 rounded-md bg-green-600/20 px-2.5 py-1 text-green-400 text-xs transition-colors hover:bg-green-600/30"
|
||||||
|
title="Keep All"
|
||||||
|
aria-label="Keep All"
|
||||||
>
|
>
|
||||||
<Check className="size-3.5" />
|
<Check className="size-3.5" />
|
||||||
Keep All
|
<span className="whitespace-nowrap">Keep All</span>
|
||||||
<kbd className="ml-1 rounded bg-green-600/20 px-1 py-0.5 font-mono text-[10px]">
|
<kbd className="ml-1 rounded bg-green-600/20 px-1 py-0.5 font-mono text-[10px]">
|
||||||
⌘Y
|
⌘Y
|
||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onUndo}
|
onClick={onUndo}
|
||||||
className="flex items-center gap-1 rounded-md bg-red-600/20 px-2.5 py-1 text-red-400 text-xs hover:bg-red-600/30 transition-colors"
|
className="flex items-center justify-center gap-1 rounded-md bg-red-600/20 px-2.5 py-1 text-red-400 text-xs transition-colors hover:bg-red-600/30"
|
||||||
|
title="Undo All"
|
||||||
|
aria-label="Undo All"
|
||||||
>
|
>
|
||||||
<X className="size-3.5" />
|
<X className="size-3.5" />
|
||||||
Undo All
|
<span className="whitespace-nowrap">Undo All</span>
|
||||||
<kbd className="ml-1 rounded bg-red-600/20 px-1 py-0.5 font-mono text-[10px]">
|
<kbd className="ml-1 rounded bg-red-600/20 px-1 py-0.5 font-mono text-[10px]">
|
||||||
⌘N
|
⌘N
|
||||||
</kbd>
|
</kbd>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useMemo } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { HistoryIcon, PlusIcon, CheckIcon, Loader2Icon } from "lucide-react";
|
import {
|
||||||
|
HistoryIcon,
|
||||||
|
PlusIcon,
|
||||||
|
CheckIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
Trash2Icon,
|
||||||
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
|
|
@ -9,8 +15,20 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { useClaudeChatStore } from "@/stores/claude-chat-store";
|
import { useClaudeChatStore } from "@/stores/claude-chat-store";
|
||||||
import { useDocumentStore } from "@/stores/document-store";
|
import { useDocumentStore } from "@/stores/document-store";
|
||||||
|
import { createLogger } from "@/lib/debug/logger";
|
||||||
|
|
||||||
|
const log = createLogger("session-selector");
|
||||||
|
|
||||||
interface ClaudeSessionInfo {
|
interface ClaudeSessionInfo {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
|
|
@ -34,30 +52,53 @@ function formatRelativeTime(unixSeconds: number): string {
|
||||||
export function SessionSelector() {
|
export function SessionSelector() {
|
||||||
const [sessions, setSessions] = useState<ClaudeSessionInfo[]>([]);
|
const [sessions, setSessions] = useState<ClaudeSessionInfo[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ClaudeSessionInfo | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
const sessionId = useClaudeChatStore((s) => s.sessionId);
|
const sessionId = useClaudeChatStore((s) => s.sessionId);
|
||||||
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
|
const tabs = useClaudeChatStore((s) => s.tabs);
|
||||||
const newSession = useClaudeChatStore((s) => s.newSession);
|
const newSession = useClaudeChatStore((s) => s.newSession);
|
||||||
const resumeSession = useClaudeChatStore((s) => s.resumeSession);
|
const resumeSession = useClaudeChatStore((s) => s.resumeSession);
|
||||||
|
const setSessionTitle = useClaudeChatStore((s) => s._setSessionTitle);
|
||||||
const projectRoot = useDocumentStore((s) => s.projectRoot);
|
const projectRoot = useDocumentStore((s) => s.projectRoot);
|
||||||
|
const streamingSessionIds = useMemo(
|
||||||
|
() =>
|
||||||
|
new Set(
|
||||||
|
tabs
|
||||||
|
.filter(
|
||||||
|
(tab) =>
|
||||||
|
tab.projectPath === projectRoot &&
|
||||||
|
tab.isStreaming &&
|
||||||
|
tab.sessionId,
|
||||||
|
)
|
||||||
|
.map((tab) => tab.sessionId as string),
|
||||||
|
),
|
||||||
|
[projectRoot, tabs],
|
||||||
|
);
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
if (!projectRoot) return;
|
if (!projectRoot) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
console.log("[session-selector] loading sessions for projectRoot:", projectRoot);
|
log.debug(`loading sessions for projectRoot: ${projectRoot}`);
|
||||||
try {
|
try {
|
||||||
const result = await invoke<ClaudeSessionInfo[]>(
|
const result = await invoke<ClaudeSessionInfo[]>("list_claude_sessions", {
|
||||||
"list_claude_sessions",
|
projectPath: projectRoot,
|
||||||
{ projectPath: projectRoot },
|
generateTitles: false,
|
||||||
);
|
});
|
||||||
console.log("[session-selector] loaded sessions:", result);
|
log.debug("loaded sessions", { count: result.length });
|
||||||
setSessions(result);
|
setSessions(result);
|
||||||
|
for (const session of result) {
|
||||||
|
setSessionTitle(session.session_id, session.title);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[session-selector] Failed to load sessions:", err);
|
log.error("Failed to load sessions", { error: String(err) });
|
||||||
setSessions([]);
|
setSessions([]);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [projectRoot]);
|
}, [projectRoot, setSessionTitle]);
|
||||||
|
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
(open: boolean) => {
|
(open: boolean) => {
|
||||||
|
|
@ -69,77 +110,204 @@ export function SessionSelector() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectSession = useCallback(
|
const handleSelectSession = useCallback(
|
||||||
(sid: string) => {
|
(session: ClaudeSessionInfo) => {
|
||||||
if (isStreaming) return;
|
if (deletingId === session.session_id) return;
|
||||||
if (sid === sessionId) return;
|
if (session.session_id === sessionId) return;
|
||||||
console.log("[session-selector] selecting session:", sid);
|
log.debug(`selecting session: ${session.session_id}`);
|
||||||
resumeSession(sid);
|
resumeSession(session.session_id, session.title);
|
||||||
},
|
},
|
||||||
[isStreaming, sessionId, resumeSession],
|
[deletingId, sessionId, resumeSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteSession = useCallback(
|
||||||
|
async (sid: string) => {
|
||||||
|
if (deletingId || !projectRoot || streamingSessionIds.has(sid)) return;
|
||||||
|
|
||||||
|
setDeleteError(null);
|
||||||
|
setDeletingId(sid);
|
||||||
|
try {
|
||||||
|
await invoke("delete_claude_session", {
|
||||||
|
projectPath: projectRoot,
|
||||||
|
sessionId: sid,
|
||||||
|
});
|
||||||
|
setSessions((prev) => prev.filter((item) => item.session_id !== sid));
|
||||||
|
if (sid === sessionId) {
|
||||||
|
newSession();
|
||||||
|
}
|
||||||
|
setDeleteTarget((current) =>
|
||||||
|
current?.session_id === sid ? null : current,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
log.error("Failed to delete session", {
|
||||||
|
sessionId: sid,
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
setDeleteError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setDeletingId((current) => (current === sid ? null : current));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deletingId, newSession, projectRoot, sessionId, streamingSessionIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNewChat = useCallback(() => {
|
const handleNewChat = useCallback(() => {
|
||||||
if (isStreaming) return;
|
|
||||||
newSession();
|
newSession();
|
||||||
}, [isStreaming, newSession]);
|
}, [newSession]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu onOpenChange={handleOpenChange}>
|
<>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu onOpenChange={handleOpenChange}>
|
||||||
<button
|
<DropdownMenuTrigger asChild>
|
||||||
type="button"
|
<button
|
||||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
type="button"
|
||||||
aria-label="Session history"
|
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
aria-label="Session history"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<HistoryIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
side="bottom"
|
||||||
|
className="max-h-80 w-72 overflow-y-auto"
|
||||||
>
|
>
|
||||||
<HistoryIcon className="size-4" />
|
<DropdownMenuLabel>Sessions</DropdownMenuLabel>
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent
|
<DropdownMenuItem onSelect={handleNewChat}>
|
||||||
align="end"
|
<PlusIcon className="size-4" />
|
||||||
side="bottom"
|
<span>New Chat</span>
|
||||||
className="max-h-80 w-72 overflow-y-auto"
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<div className="px-2 py-4 text-center text-muted-foreground text-sm">
|
||||||
|
No previous sessions
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sessions.map((session) => {
|
||||||
|
const sessionIsStreaming = streamingSessionIds.has(
|
||||||
|
session.session_id,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={session.session_id}
|
||||||
|
onSelect={() => handleSelectSession(session)}
|
||||||
|
disabled={deletingId === session.session_id}
|
||||||
|
className="group flex items-start gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate text-sm">{session.title}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{formatRelativeTime(session.last_modified)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{sessionIsStreaming ? (
|
||||||
|
<Loader2Icon className="size-4 animate-spin text-primary" />
|
||||||
|
) : (
|
||||||
|
session.session_id === sessionId && (
|
||||||
|
<CheckIcon className="size-4 text-primary" />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex size-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
|
||||||
|
aria-label={`Delete ${session.title}`}
|
||||||
|
title={
|
||||||
|
sessionIsStreaming
|
||||||
|
? "Cannot delete a running session"
|
||||||
|
: "Delete session"
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
sessionIsStreaming || deletingId === session.session_id
|
||||||
|
}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setDeleteError(null);
|
||||||
|
setDeleteTarget(session);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deletingId === session.session_id ? (
|
||||||
|
<Loader2Icon className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !deletingId) {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setDeleteError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenuLabel>Sessions</DropdownMenuLabel>
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
<DropdownMenuItem onSelect={handleNewChat} disabled={isStreaming}>
|
<DialogTitle>Delete Chat</DialogTitle>
|
||||||
<PlusIcon className="size-4" />
|
<DialogDescription>
|
||||||
<span>New Chat</span>
|
Delete "{deleteTarget?.title || "this session"}" from this
|
||||||
</DropdownMenuItem>
|
project?
|
||||||
|
</DialogDescription>
|
||||||
<DropdownMenuSeparator />
|
</DialogHeader>
|
||||||
|
{deleteError && (
|
||||||
{isLoading ? (
|
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive text-xs">
|
||||||
<div className="flex items-center justify-center py-4">
|
{deleteError}
|
||||||
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
|
</p>
|
||||||
</div>
|
)}
|
||||||
) : sessions.length === 0 ? (
|
<DialogFooter>
|
||||||
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
|
<Button
|
||||||
No previous sessions
|
variant="outline"
|
||||||
</div>
|
onClick={() => {
|
||||||
) : (
|
if (deletingId) return;
|
||||||
sessions.map((session) => (
|
setDeleteTarget(null);
|
||||||
<DropdownMenuItem
|
setDeleteError(null);
|
||||||
key={session.session_id}
|
}}
|
||||||
onSelect={() => handleSelectSession(session.session_id)}
|
disabled={!!deletingId}
|
||||||
disabled={isStreaming}
|
|
||||||
className="flex items-start gap-2"
|
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
Cancel
|
||||||
<span className="truncate text-sm">{session.title}</span>
|
</Button>
|
||||||
<span className="text-xs text-muted-foreground">
|
<Button
|
||||||
{formatRelativeTime(session.last_modified)}
|
variant="destructive"
|
||||||
</span>
|
onClick={() => {
|
||||||
</div>
|
if (deleteTarget) {
|
||||||
{session.session_id === sessionId && (
|
void handleDeleteSession(deleteTarget.session_id);
|
||||||
<CheckIcon className="size-4 shrink-0 text-primary" />
|
}
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
!deleteTarget ||
|
||||||
|
!!deletingId ||
|
||||||
|
streamingSessionIds.has(deleteTarget.session_id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{deletingId ? (
|
||||||
|
<Loader2Icon className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
Delete
|
||||||
))
|
</Button>
|
||||||
)}
|
</DialogFooter>
|
||||||
</DropdownMenuContent>
|
</DialogContent>
|
||||||
</DropdownMenu>
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,27 @@
|
||||||
import { type FC, type RefObject, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import {
|
||||||
|
type FC,
|
||||||
|
type RefObject,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { CommandIcon, FolderOpenIcon, GlobeIcon, TerminalIcon, FileCodeIcon, ZapIcon, XIcon, SearchIcon, FlaskConicalIcon, ChevronRightIcon, ChevronLeftIcon } from "lucide-react";
|
import {
|
||||||
|
CommandIcon,
|
||||||
|
FolderOpenIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
TerminalIcon,
|
||||||
|
FileCodeIcon,
|
||||||
|
ZapIcon,
|
||||||
|
XIcon,
|
||||||
|
SearchIcon,
|
||||||
|
FlaskConicalIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
|
|
@ -43,12 +63,22 @@ function scopeToTab(scope: string): Tab {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCommandIcon(command: SlashCommand) {
|
function getCommandIcon(command: SlashCommand) {
|
||||||
if (command.scope === "skill") return <FlaskConicalIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
if (command.scope === "skill")
|
||||||
if (command.has_bash_commands) return <TerminalIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
return (
|
||||||
if (command.has_file_references) return <FileCodeIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
<FlaskConicalIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
if (command.scope === "project") return <FolderOpenIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
);
|
||||||
if (command.scope === "user") return <GlobeIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
if (command.has_bash_commands)
|
||||||
if (command.scope === "default") return <CommandIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
return <TerminalIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||||
|
if (command.has_file_references)
|
||||||
|
return <FileCodeIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||||
|
if (command.scope === "project")
|
||||||
|
return (
|
||||||
|
<FolderOpenIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
);
|
||||||
|
if (command.scope === "user")
|
||||||
|
return <GlobeIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||||
|
if (command.scope === "default")
|
||||||
|
return <CommandIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||||
return <ZapIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
return <ZapIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,9 +94,13 @@ const SCORE_MATCH_CAPITAL = 0.7;
|
||||||
const SCORE_MATCH_DOT = 0.6;
|
const SCORE_MATCH_DOT = 0.6;
|
||||||
const SCORE_MAX_LEADING_GAP = -0.05;
|
const SCORE_MAX_LEADING_GAP = -0.05;
|
||||||
|
|
||||||
function isWordBoundary(prev: string, curr: string): boolean {
|
function _isWordBoundary(prev: string, curr: string): boolean {
|
||||||
return (
|
return (
|
||||||
prev === "-" || prev === "_" || prev === " " || prev === "/" || prev === "." ||
|
prev === "-" ||
|
||||||
|
prev === "_" ||
|
||||||
|
prev === " " ||
|
||||||
|
prev === "/" ||
|
||||||
|
prev === "." ||
|
||||||
(prev === prev.toLowerCase() && curr === curr.toUpperCase())
|
(prev === prev.toLowerCase() && curr === curr.toUpperCase())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -75,7 +109,8 @@ function bonusFor(prev: string, curr: string): number {
|
||||||
if (prev === "/") return SCORE_MATCH_SLASH;
|
if (prev === "/") return SCORE_MATCH_SLASH;
|
||||||
if (prev === "-" || prev === "_" || prev === " ") return SCORE_MATCH_WORD;
|
if (prev === "-" || prev === "_" || prev === " ") return SCORE_MATCH_WORD;
|
||||||
if (prev === ".") return SCORE_MATCH_DOT;
|
if (prev === ".") return SCORE_MATCH_DOT;
|
||||||
if (prev === prev.toLowerCase() && curr === curr.toUpperCase()) return SCORE_MATCH_CAPITAL;
|
if (prev === prev.toLowerCase() && curr === curr.toUpperCase())
|
||||||
|
return SCORE_MATCH_CAPITAL;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,14 +152,17 @@ function fuzzyScore(query: string, candidate: string): number {
|
||||||
|
|
||||||
if (i === 0) {
|
if (i === 0) {
|
||||||
// First char of query
|
// First char of query
|
||||||
score = j === 0
|
score =
|
||||||
? SCORE_MATCH_CONSECUTIVE // start of string
|
j === 0
|
||||||
: Math.max(SCORE_MAX_LEADING_GAP, SCORE_GAP_LEADING * j) + bonusFor(candidate[j - 1], candidate[j]);
|
? SCORE_MATCH_CONSECUTIVE // start of string
|
||||||
|
: Math.max(SCORE_MAX_LEADING_GAP, SCORE_GAP_LEADING * j) +
|
||||||
|
bonusFor(candidate[j - 1], candidate[j]);
|
||||||
} else if (j > 0) {
|
} else if (j > 0) {
|
||||||
// Consecutive match bonus
|
// Consecutive match bonus
|
||||||
const consecutive = D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE;
|
const consecutive = D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE;
|
||||||
// Non-consecutive: gap penalty from best previous match
|
// Non-consecutive: gap penalty from best previous match
|
||||||
const boundary = M[i - 1][j - 1] + bonusFor(candidate[j - 1], candidate[j]);
|
const boundary =
|
||||||
|
M[i - 1][j - 1] + bonusFor(candidate[j - 1], candidate[j]);
|
||||||
score = Math.max(consecutive, boundary);
|
score = Math.max(consecutive, boundary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,7 +245,7 @@ function scoreCommand(cmd: SlashCommand, q: string): number {
|
||||||
|
|
||||||
// 2. Description: only substring (contains) match to avoid false positives on long text
|
// 2. Description: only substring (contains) match to avoid false positives on long text
|
||||||
let descScore = -Infinity;
|
let descScore = -Infinity;
|
||||||
if (cmd.description && cmd.description.toLowerCase().includes(q.toLowerCase())) {
|
if (cmd.description?.toLowerCase().includes(q.toLowerCase())) {
|
||||||
descScore = q.length * 0.3; // modest score for description-only match
|
descScore = q.length * 0.3; // modest score for description-only match
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -215,10 +253,7 @@ function scoreCommand(cmd: SlashCommand, q: string): number {
|
||||||
if (fuzzy > -Infinity) return fuzzy;
|
if (fuzzy > -Infinity) return fuzzy;
|
||||||
|
|
||||||
// 3. Typo-tolerant fallback on command key / name (handles bioarxiv → biorxiv)
|
// 3. Typo-tolerant fallback on command key / name (handles bioarxiv → biorxiv)
|
||||||
const typo = Math.max(
|
const typo = Math.max(typoScore(q, cmdKey), typoScore(q, cmd.name));
|
||||||
typoScore(q, cmdKey),
|
|
||||||
typoScore(q, cmd.name),
|
|
||||||
);
|
|
||||||
|
|
||||||
return typo;
|
return typo;
|
||||||
}
|
}
|
||||||
|
|
@ -252,23 +287,58 @@ function SkillPreview({ content }: { content: string }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="prose prose-sm prose-invert max-w-none text-xs leading-relaxed text-muted-foreground">
|
<div className="prose prose-sm prose-invert max-w-none text-muted-foreground text-xs leading-relaxed">
|
||||||
{body.split("\n").map((line, i) => {
|
{body.split("\n").map((line, i) => {
|
||||||
const trimmed = line.trimEnd();
|
const trimmed = line.trimEnd();
|
||||||
if (trimmed.startsWith("# ")) {
|
if (trimmed.startsWith("# ")) {
|
||||||
return <h3 key={i} className="text-sm font-semibold text-foreground mt-3 mb-1">{trimmed.slice(2)}</h3>;
|
return (
|
||||||
|
<h3
|
||||||
|
key={i}
|
||||||
|
className="mt-3 mb-1 font-semibold text-foreground text-sm"
|
||||||
|
>
|
||||||
|
{trimmed.slice(2)}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (trimmed.startsWith("## ")) {
|
if (trimmed.startsWith("## ")) {
|
||||||
return <h4 key={i} className="text-xs font-semibold text-foreground mt-2.5 mb-0.5">{trimmed.slice(3)}</h4>;
|
return (
|
||||||
|
<h4
|
||||||
|
key={i}
|
||||||
|
className="mt-2.5 mb-0.5 font-semibold text-foreground text-xs"
|
||||||
|
>
|
||||||
|
{trimmed.slice(3)}
|
||||||
|
</h4>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (trimmed.startsWith("### ")) {
|
if (trimmed.startsWith("### ")) {
|
||||||
return <h5 key={i} className="text-xs font-medium text-foreground mt-2 mb-0.5">{trimmed.slice(4)}</h5>;
|
return (
|
||||||
|
<h5
|
||||||
|
key={i}
|
||||||
|
className="mt-2 mb-0.5 font-medium text-foreground text-xs"
|
||||||
|
>
|
||||||
|
{trimmed.slice(4)}
|
||||||
|
</h5>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) {
|
if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) {
|
||||||
return <div key={i} className="pl-3 before:content-['·'] before:mr-1.5 before:text-muted-foreground/50">{trimmed.slice(2)}</div>;
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="pl-3 before:mr-1.5 before:text-muted-foreground/50 before:content-['·']"
|
||||||
|
>
|
||||||
|
{trimmed.slice(2)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (trimmed.startsWith("```")) {
|
if (trimmed.startsWith("```")) {
|
||||||
return <div key={i} className="font-mono text-[10px] text-muted-foreground/70">{trimmed}</div>;
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="font-mono text-[10px] text-muted-foreground/70"
|
||||||
|
>
|
||||||
|
{trimmed}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (trimmed === "") {
|
if (trimmed === "") {
|
||||||
return <div key={i} className="h-1.5" />;
|
return <div key={i} className="h-1.5" />;
|
||||||
|
|
@ -292,7 +362,11 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
||||||
const [activeTab, setActiveTab] = useState<Tab>("skills");
|
const [activeTab, setActiveTab] = useState<Tab>("skills");
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const [pos, setPos] = useState<{ left: number; right: number; bottom: number }>({ left: 0, right: 0, bottom: 0 });
|
const [pos, setPos] = useState<{
|
||||||
|
left: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
}>({ left: 0, right: 0, bottom: 0 });
|
||||||
|
|
||||||
const isSearching = query.length > 0;
|
const isSearching = query.length > 0;
|
||||||
|
|
||||||
|
|
@ -352,9 +426,16 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
||||||
const searchGroups = useMemo(() => {
|
const searchGroups = useMemo(() => {
|
||||||
if (!isSearching) return null;
|
if (!isSearching) return null;
|
||||||
|
|
||||||
const groups: { label: string; items: { cmd: SlashCommand; globalIndex: number }[] }[] = [];
|
const groups: {
|
||||||
|
label: string;
|
||||||
|
items: { cmd: SlashCommand; globalIndex: number }[];
|
||||||
|
}[] = [];
|
||||||
const order: Tab[] = ["skills", "default", "custom"];
|
const order: Tab[] = ["skills", "default", "custom"];
|
||||||
const labels: Record<Tab, string> = { skills: "Skills", default: "Default", custom: "Custom" };
|
const labels: Record<Tab, string> = {
|
||||||
|
skills: "Skills",
|
||||||
|
default: "Default",
|
||||||
|
custom: "Custom",
|
||||||
|
};
|
||||||
|
|
||||||
for (const tab of order) {
|
for (const tab of order) {
|
||||||
const items: { cmd: SlashCommand; globalIndex: number }[] = [];
|
const items: { cmd: SlashCommand; globalIndex: number }[] = [];
|
||||||
|
|
@ -430,7 +511,9 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
||||||
// Scroll selected item into view
|
// Scroll selected item into view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (listRef.current) {
|
if (listRef.current) {
|
||||||
const selected = listRef.current.querySelector(`[data-index="${selectedIndex}"]`);
|
const selected = listRef.current.querySelector(
|
||||||
|
`[data-index="${selectedIndex}"]`,
|
||||||
|
);
|
||||||
selected?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
selected?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
}
|
}
|
||||||
}, [selectedIndex]);
|
}, [selectedIndex]);
|
||||||
|
|
@ -443,7 +526,7 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
||||||
key={cmd.id}
|
key={cmd.id}
|
||||||
data-index={index}
|
data-index={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2.5 px-3 py-1.5 rounded-md text-left transition-colors",
|
"flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-left transition-colors",
|
||||||
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted",
|
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted",
|
||||||
)}
|
)}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
|
|
@ -453,9 +536,9 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
||||||
onMouseEnter={() => setSelectedIndex(index)}
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
>
|
>
|
||||||
{getCommandIcon(cmd)}
|
{getCommandIcon(cmd)}
|
||||||
<span className="font-mono text-sm truncate">{cmd.full_command}</span>
|
<span className="truncate font-mono text-sm">{cmd.full_command}</span>
|
||||||
{cmd.description && (
|
{cmd.description && (
|
||||||
<span className="truncate text-xs text-muted-foreground flex-1 min-w-0">
|
<span className="min-w-0 flex-1 truncate text-muted-foreground text-xs">
|
||||||
{cmd.description}
|
{cmd.description}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -482,29 +565,34 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
||||||
if (isSearching) {
|
if (isSearching) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-8">
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
<SearchIcon className="size-6 text-muted-foreground mb-2" />
|
<SearchIcon className="mb-2 size-6 text-muted-foreground" />
|
||||||
<span className="text-sm text-muted-foreground">No results for "{query}"</span>
|
<span className="text-muted-foreground text-sm">
|
||||||
|
No results for "{query}"
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hints: Record<Tab, React.ReactNode> = {
|
const hints: Record<Tab, React.ReactNode> = {
|
||||||
skills: (
|
skills: (
|
||||||
<p className="text-xs text-muted-foreground mt-1 text-center px-4">
|
<p className="mt-1 px-4 text-center text-muted-foreground text-xs">
|
||||||
Install scientific skills from the sidebar menu.
|
Install scientific skills from the sidebar menu.
|
||||||
</p>
|
</p>
|
||||||
),
|
),
|
||||||
default: null,
|
default: null,
|
||||||
custom: (
|
custom: (
|
||||||
<p className="text-xs text-muted-foreground mt-1 text-center px-4">
|
<p className="mt-1 px-4 text-center text-muted-foreground text-xs">
|
||||||
Add commands in <code className="px-1">.claude/commands/</code> or <code className="px-1">~/.claude/commands/</code>
|
Add commands in <code className="px-1">.claude/commands/</code> or{" "}
|
||||||
|
<code className="px-1">~/.claude/commands/</code>
|
||||||
</p>
|
</p>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-8">
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
<span className="text-sm text-muted-foreground">No commands available</span>
|
<span className="text-muted-foreground text-sm">
|
||||||
|
No commands available
|
||||||
|
</span>
|
||||||
{hints[activeTab]}
|
{hints[activeTab]}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -514,7 +602,7 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
||||||
<div className="flex-1 overflow-y-auto" ref={listRef}>
|
<div className="flex-1 overflow-y-auto" ref={listRef}>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<span className="text-sm text-muted-foreground">Loading...</span>
|
<span className="text-muted-foreground text-sm">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -526,7 +614,7 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{searchGroups.map((group) => (
|
{searchGroups.map((group) => (
|
||||||
<div key={group.label}>
|
<div key={group.label}>
|
||||||
<h3 className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
<h3 className="px-3 py-1 font-semibold text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||||
{group.label}
|
{group.label}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
|
|
@ -559,16 +647,18 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Left side: list */}
|
{/* Left side: list */}
|
||||||
<div className={cn(
|
<div
|
||||||
"flex flex-col overflow-hidden transition-all",
|
className={cn(
|
||||||
showPreview ? "w-[45%] min-w-[200px]" : "w-full",
|
"flex flex-col overflow-hidden transition-all",
|
||||||
)}>
|
showPreview ? "w-[45%] min-w-[200px]" : "w-full",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="border-b border-border px-3 pt-2.5 pb-2 shrink-0">
|
<div className="shrink-0 border-border border-b px-3 pt-2.5 pb-2">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CommandIcon className="size-4 text-muted-foreground" />
|
<CommandIcon className="size-4 text-muted-foreground" />
|
||||||
<span className="text-sm font-medium">
|
<span className="font-medium text-sm">
|
||||||
{isSearching ? `Search: "${query}"` : "Commands"}
|
{isSearching ? `Search: "${query}"` : "Commands"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -590,7 +680,7 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
"flex items-center gap-1.5 rounded-md px-2.5 py-1 font-medium text-xs transition-colors",
|
||||||
activeTab === tab
|
activeTab === tab
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "bg-muted text-muted-foreground hover:bg-muted/80",
|
: "bg-muted text-muted-foreground hover:bg-muted/80",
|
||||||
|
|
@ -600,14 +690,20 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
||||||
setActiveTab(tab);
|
setActiveTab(tab);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tab === "skills" ? "Skills" : tab === "default" ? "Default" : "Custom"}
|
{tab === "skills"
|
||||||
|
? "Skills"
|
||||||
|
: tab === "default"
|
||||||
|
? "Default"
|
||||||
|
: "Custom"}
|
||||||
{tabCounts[tab] > 0 && (
|
{tabCounts[tab] > 0 && (
|
||||||
<span className={cn(
|
<span
|
||||||
"rounded-full px-1.5 text-[10px] leading-4 tabular-nums",
|
className={cn(
|
||||||
activeTab === tab
|
"rounded-full px-1.5 text-[10px] tabular-nums leading-4",
|
||||||
? "bg-primary-foreground/20 text-primary-foreground"
|
activeTab === tab
|
||||||
: "bg-muted-foreground/15 text-muted-foreground",
|
? "bg-primary-foreground/20 text-primary-foreground"
|
||||||
)}>
|
: "bg-muted-foreground/15 text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{tabCounts[tab]}
|
{tabCounts[tab]}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -620,18 +716,19 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
||||||
{renderList()}
|
{renderList()}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="border-t border-border px-3 py-1 shrink-0">
|
<div className="shrink-0 border-border border-t px-3 py-1">
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
↑↓ Navigate · Enter Select · {canPreview ? "→ Preview · " : ""}Esc Close
|
↑↓ Navigate · Enter Select · {canPreview ? "→ Preview · " : ""}Esc
|
||||||
|
Close
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side: preview panel */}
|
{/* Right side: preview panel */}
|
||||||
{showPreview && selectedCommand && (
|
{showPreview && selectedCommand && (
|
||||||
<div className="flex flex-col w-[55%] border-l border-border">
|
<div className="flex w-[55%] flex-col border-border border-l">
|
||||||
{/* Preview header */}
|
{/* Preview header */}
|
||||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2 shrink-0">
|
<div className="flex shrink-0 items-center gap-2 border-border border-b px-3 py-2">
|
||||||
<button
|
<button
|
||||||
aria-label="Close preview"
|
aria-label="Close preview"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
|
|
@ -643,7 +740,9 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
||||||
<ChevronLeftIcon className="size-3.5 text-muted-foreground" />
|
<ChevronLeftIcon className="size-3.5 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
<FlaskConicalIcon className="size-3.5 text-muted-foreground" />
|
<FlaskConicalIcon className="size-3.5 text-muted-foreground" />
|
||||||
<span className="font-mono text-sm font-medium truncate">{selectedCommand.full_command}</span>
|
<span className="truncate font-medium font-mono text-sm">
|
||||||
|
{selectedCommand.full_command}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview body */}
|
{/* Preview body */}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { type FC, useState } from "react";
|
import { type FC, useState } from "react";
|
||||||
import {
|
import {
|
||||||
BotIcon,
|
BrainIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
CircleDotIcon,
|
|
||||||
CircleIcon,
|
CircleIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
FileEditIcon,
|
FileEditIcon,
|
||||||
|
|
@ -17,7 +16,10 @@ import {
|
||||||
TerminalIcon,
|
TerminalIcon,
|
||||||
WrenchIcon,
|
WrenchIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useClaudeChatStore, type ContentBlock } from "@/stores/claude-chat-store";
|
import {
|
||||||
|
useClaudeChatStore,
|
||||||
|
type ContentBlock,
|
||||||
|
} from "@/stores/claude-chat-store";
|
||||||
|
|
||||||
interface ToolWidgetProps {
|
interface ToolWidgetProps {
|
||||||
toolUse: ContentBlock;
|
toolUse: ContentBlock;
|
||||||
|
|
@ -27,16 +29,36 @@ interface ToolWidgetProps {
|
||||||
export const ToolWidget: FC<ToolWidgetProps> = ({ toolUse, toolResult }) => {
|
export const ToolWidget: FC<ToolWidgetProps> = ({ toolUse, toolResult }) => {
|
||||||
const name = toolUse.name?.toLowerCase() || "";
|
const name = toolUse.name?.toLowerCase() || "";
|
||||||
|
|
||||||
if (name === "write") return <WriteWidget input={toolUse.input} result={toolResult} />;
|
if (name === "write")
|
||||||
if (name === "edit" || name === "multiedit") return <EditWidget input={toolUse.input} result={toolResult} />;
|
return <WriteWidget input={toolUse.input} result={toolResult} />;
|
||||||
if (name === "read") return <ReadWidget input={toolUse.input} result={toolResult} />;
|
if (name === "edit" || name === "multiedit")
|
||||||
if (name === "bash") return <BashWidget input={toolUse.input} result={toolResult} />;
|
return <EditWidget input={toolUse.input} result={toolResult} />;
|
||||||
if (name === "glob") return <GlobWidget input={toolUse.input} result={toolResult} />;
|
if (name === "read")
|
||||||
if (name === "grep") return <GrepWidget input={toolUse.input} result={toolResult} />;
|
return <ReadWidget input={toolUse.input} result={toolResult} />;
|
||||||
if (name === "askuserquestion") return <AskUserQuestionWidget input={toolUse.input} result={toolResult} />;
|
if (name === "bash")
|
||||||
if (name === "todowrite") return <TodoWriteWidget input={toolUse.input} result={toolResult} />;
|
return <BashWidget input={toolUse.input} result={toolResult} />;
|
||||||
|
if (name === "powershell" || name === "pwsh")
|
||||||
|
return (
|
||||||
|
<BashWidget input={toolUse.input} result={toolResult} prefix="PS>" />
|
||||||
|
);
|
||||||
|
if (name === "glob")
|
||||||
|
return <GlobWidget input={toolUse.input} result={toolResult} />;
|
||||||
|
if (name === "grep")
|
||||||
|
return <GrepWidget input={toolUse.input} result={toolResult} />;
|
||||||
|
if (name === "askuserquestion")
|
||||||
|
return <AskUserQuestionWidget input={toolUse.input} result={toolResult} />;
|
||||||
|
if (name === "exitplanmode")
|
||||||
|
return <ExitPlanModeWidget input={toolUse.input} result={toolResult} />;
|
||||||
|
if (name === "todowrite")
|
||||||
|
return <TodoWriteWidget input={toolUse.input} result={toolResult} />;
|
||||||
|
|
||||||
return <GenericWidget name={toolUse.name || "unknown"} input={toolUse.input} result={toolResult} />;
|
return (
|
||||||
|
<GenericWidget
|
||||||
|
name={toolUse.name || "unknown"}
|
||||||
|
input={toolUse.input}
|
||||||
|
result={toolResult}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Status Icon ───
|
// ─── Status Icon ───
|
||||||
|
|
@ -48,24 +70,31 @@ const StatusIcon: FC<{ result?: ContentBlock }> = ({ result }) => {
|
||||||
// Tool was cancelled (stop pressed) — show stopped state
|
// Tool was cancelled (stop pressed) — show stopped state
|
||||||
return <CircleIcon className="size-3.5 text-muted-foreground" />;
|
return <CircleIcon className="size-3.5 text-muted-foreground" />;
|
||||||
}
|
}
|
||||||
return <LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />;
|
return (
|
||||||
|
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (result.is_error) {
|
if (result.is_error) {
|
||||||
return <span className="text-sm text-destructive">!</span>;
|
return <span className="text-destructive text-sm">!</span>;
|
||||||
}
|
}
|
||||||
return <CheckIcon className="size-3.5 text-green-600" />;
|
return <CheckIcon className="size-3.5 text-green-600" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Write Widget ───
|
// ─── Write Widget ───
|
||||||
|
|
||||||
const WriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
|
const WriteWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
||||||
|
input,
|
||||||
|
result,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="my-1.5 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
|
<div className="my-1.5 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
|
||||||
<StatusIcon result={result} />
|
<StatusIcon result={result} />
|
||||||
<FileOutputIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
<FileOutputIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span className="min-w-0 truncate text-muted-foreground">
|
<span className="min-w-0 truncate text-muted-foreground">
|
||||||
{result ? "Wrote" : "Writing"}{" "}
|
{result ? "Wrote" : "Writing"}{" "}
|
||||||
<code className="rounded bg-muted px-1 text-xs">{input?.file_path}</code>
|
<code className="rounded bg-muted px-1 text-xs">
|
||||||
|
{input?.file_path}
|
||||||
|
</code>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -73,7 +102,10 @@ const WriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result
|
||||||
|
|
||||||
// ─── Edit Widget ───
|
// ─── Edit Widget ───
|
||||||
|
|
||||||
const EditWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
|
const EditWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
||||||
|
input,
|
||||||
|
result,
|
||||||
|
}) => {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -87,18 +119,25 @@ const EditWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
|
||||||
<FileEditIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
<FileEditIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span className="min-w-0 truncate text-muted-foreground">
|
<span className="min-w-0 truncate text-muted-foreground">
|
||||||
{result ? "Edited" : "Editing"}{" "}
|
{result ? "Edited" : "Editing"}{" "}
|
||||||
<code className="rounded bg-muted px-1 text-xs">{input?.file_path}</code>
|
<code className="rounded bg-muted px-1 text-xs">
|
||||||
|
{input?.file_path}
|
||||||
|
</code>
|
||||||
</span>
|
</span>
|
||||||
{(input?.old_string || input?.edits) && (
|
{(input?.old_string || input?.edits) &&
|
||||||
expanded
|
(expanded ? (
|
||||||
? <ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
|
<ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
|
||||||
: <ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />
|
) : (
|
||||||
)}
|
<ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />
|
||||||
|
))}
|
||||||
</button>
|
</button>
|
||||||
{expanded && input?.old_string && (
|
{expanded && input?.old_string && (
|
||||||
<div className="border-t border-border px-3 py-2 font-mono text-xs">
|
<div className="border-border border-t px-3 py-2 font-mono text-xs">
|
||||||
<div className="mb-1 text-red-500">- {truncate(input.old_string, 200)}</div>
|
<div className="mb-1 text-red-500">
|
||||||
<div className="text-green-500">+ {truncate(input.new_string, 200)}</div>
|
- {truncate(input.old_string, 200)}
|
||||||
|
</div>
|
||||||
|
<div className="text-green-500">
|
||||||
|
+ {truncate(input.new_string, 200)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -107,14 +146,19 @@ const EditWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
|
||||||
|
|
||||||
// ─── Read Widget ───
|
// ─── Read Widget ───
|
||||||
|
|
||||||
const ReadWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
|
const ReadWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
||||||
|
input,
|
||||||
|
result,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="my-1.5 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
|
<div className="my-1.5 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
|
||||||
<StatusIcon result={result} />
|
<StatusIcon result={result} />
|
||||||
<FileIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
<FileIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span className="min-w-0 truncate text-muted-foreground">
|
<span className="min-w-0 truncate text-muted-foreground">
|
||||||
{result ? "Read" : "Reading"}{" "}
|
{result ? "Read" : "Reading"}{" "}
|
||||||
<code className="rounded bg-muted px-1 text-xs">{input?.file_path}</code>
|
<code className="rounded bg-muted px-1 text-xs">
|
||||||
|
{input?.file_path}
|
||||||
|
</code>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -122,30 +166,38 @@ const ReadWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
|
||||||
|
|
||||||
// ─── Bash Widget ───
|
// ─── Bash Widget ───
|
||||||
|
|
||||||
const BashWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
|
const BashWidget: FC<{
|
||||||
|
input: any;
|
||||||
|
result?: ContentBlock;
|
||||||
|
prefix?: string;
|
||||||
|
}> = ({ input, result, prefix = "$" }) => {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const command = input?.command || input?.description || "";
|
const command = input?.command || input?.description || "";
|
||||||
const resultContent = typeof result?.content === "string" ? result.content : "";
|
const resultContent =
|
||||||
|
typeof result?.content === "string" ? result.content : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-1.5 rounded-lg border border-border bg-[#1e1e2e] text-sm">
|
<div className="my-1.5 rounded-lg border border-border bg-muted/70 text-sm dark:bg-neutral-900">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2 px-3 py-2"
|
className="flex w-full items-center gap-2 px-3 py-2"
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
>
|
>
|
||||||
<StatusIcon result={result} />
|
<StatusIcon result={result} />
|
||||||
<TerminalIcon className="size-3.5 shrink-0 text-green-400" />
|
<TerminalIcon className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||||
<code className="min-w-0 truncate text-xs text-green-300">$ {truncate(command, 80)}</code>
|
<code className="min-w-0 truncate text-emerald-700 text-xs dark:text-emerald-300">
|
||||||
{result && (
|
{prefix} {truncate(command, 80)}
|
||||||
expanded
|
</code>
|
||||||
? <ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
|
{result &&
|
||||||
: <ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />
|
(expanded ? (
|
||||||
)}
|
<ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />
|
||||||
|
))}
|
||||||
</button>
|
</button>
|
||||||
{expanded && resultContent && (
|
{expanded && resultContent && (
|
||||||
<div className="max-h-40 overflow-auto border-t border-border/50 px-3 py-2">
|
<div className="max-h-40 overflow-auto border-border/50 border-t px-3 py-2">
|
||||||
<pre className="whitespace-pre-wrap font-mono text-xs text-gray-300">
|
<pre className="whitespace-pre-wrap font-mono text-muted-foreground text-xs">
|
||||||
{truncate(resultContent, 2000)}
|
{truncate(resultContent, 2000)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -156,7 +208,10 @@ const BashWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
|
||||||
|
|
||||||
// ─── Glob Widget ───
|
// ─── Glob Widget ───
|
||||||
|
|
||||||
const GlobWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
|
const GlobWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
||||||
|
input,
|
||||||
|
result,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="my-1.5 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
|
<div className="my-1.5 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
|
||||||
<StatusIcon result={result} />
|
<StatusIcon result={result} />
|
||||||
|
|
@ -171,7 +226,10 @@ const GlobWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
|
||||||
|
|
||||||
// ─── Grep Widget ───
|
// ─── Grep Widget ───
|
||||||
|
|
||||||
const GrepWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
|
const GrepWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
||||||
|
input,
|
||||||
|
result,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="my-1.5 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
|
<div className="my-1.5 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
|
||||||
<StatusIcon result={result} />
|
<StatusIcon result={result} />
|
||||||
|
|
@ -186,7 +244,10 @@ const GrepWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
|
||||||
|
|
||||||
// ─── AskUserQuestion Widget ───
|
// ─── AskUserQuestion Widget ───
|
||||||
|
|
||||||
const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
|
const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
||||||
|
input,
|
||||||
|
result,
|
||||||
|
}) => {
|
||||||
const questions: any[] = input?.questions || [];
|
const questions: any[] = input?.questions || [];
|
||||||
const [answered, setAnswered] = useState(false);
|
const [answered, setAnswered] = useState(false);
|
||||||
|
|
||||||
|
|
@ -194,9 +255,10 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ inpu
|
||||||
// The process is killed when AskUserQuestion is detected, so result may be undefined.
|
// The process is killed when AskUserQuestion is detected, so result may be undefined.
|
||||||
// Options are clickable when there's no result or an error result.
|
// Options are clickable when there's no result or an error result.
|
||||||
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
|
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
|
||||||
const needsUserAnswer = !answered && !isStreaming && (!result || result.is_error);
|
const needsUserAnswer =
|
||||||
|
!answered && !isStreaming && (!result || result.is_error);
|
||||||
|
|
||||||
const handleOptionClick = (question: string, label: string) => {
|
const handleOptionClick = (_question: string, label: string) => {
|
||||||
const { sendPrompt, isStreaming } = useClaudeChatStore.getState();
|
const { sendPrompt, isStreaming } = useClaudeChatStore.getState();
|
||||||
if (isStreaming) return;
|
if (isStreaming) return;
|
||||||
setAnswered(true);
|
setAnswered(true);
|
||||||
|
|
@ -225,11 +287,13 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ inpu
|
||||||
: "Question answered";
|
: "Question answered";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`my-1.5 rounded-lg border text-sm ${
|
<div
|
||||||
needsUserAnswer
|
className={`my-1.5 rounded-lg border text-sm ${
|
||||||
? "border-blue-500/40 bg-blue-500/10"
|
needsUserAnswer
|
||||||
: "border-blue-500/20 bg-blue-500/5"
|
? "border-blue-500/40 bg-blue-500/10"
|
||||||
}`}>
|
: "border-blue-500/20 bg-blue-500/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2 px-3 py-2">
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
{needsUserAnswer ? (
|
{needsUserAnswer ? (
|
||||||
<MessageCircleQuestionIcon className="size-3.5 text-blue-500" />
|
<MessageCircleQuestionIcon className="size-3.5 text-blue-500" />
|
||||||
|
|
@ -243,22 +307,24 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ inpu
|
||||||
{headerLabel}
|
{headerLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3 border-t border-blue-500/20 px-3 py-2.5">
|
<div className="space-y-3 border-blue-500/20 border-t px-3 py-2.5">
|
||||||
{questions.map((q: any, qIdx: number) => (
|
{questions.map((q: any, qIdx: number) => (
|
||||||
<div key={qIdx} className="space-y-1.5">
|
<div key={qIdx} className="space-y-1.5">
|
||||||
{q.header && (
|
{q.header && (
|
||||||
<span className="inline-block rounded-full bg-blue-500/15 px-2 py-0.5 text-xs font-medium text-blue-600 dark:text-blue-400">
|
<span className="inline-block rounded-full bg-blue-500/15 px-2 py-0.5 font-medium text-blue-600 text-xs dark:text-blue-400">
|
||||||
{q.header}
|
{q.header}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm font-medium text-foreground">{q.question}</p>
|
<p className="font-medium text-foreground text-sm">{q.question}</p>
|
||||||
<div className="space-y-1 pl-1">
|
<div className="space-y-1 pl-1">
|
||||||
{q.options?.map((opt: any, oIdx: number) => (
|
{q.options?.map((opt: any, oIdx: number) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={oIdx}
|
key={oIdx}
|
||||||
disabled={!needsUserAnswer}
|
disabled={!needsUserAnswer}
|
||||||
onClick={() => needsUserAnswer && handleOptionClick(q.question, opt.label)}
|
onClick={() =>
|
||||||
|
needsUserAnswer && handleOptionClick(q.question, opt.label)
|
||||||
|
}
|
||||||
className={`flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors ${
|
className={`flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors ${
|
||||||
needsUserAnswer
|
needsUserAnswer
|
||||||
? "cursor-pointer hover:bg-blue-500/15"
|
? "cursor-pointer hover:bg-blue-500/15"
|
||||||
|
|
@ -266,18 +332,26 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ inpu
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="mt-0.5">
|
<div className="mt-0.5">
|
||||||
<CircleIcon className={`size-3.5 ${
|
<CircleIcon
|
||||||
needsUserAnswer ? "text-blue-500/50" : "text-muted-foreground/40"
|
className={`size-3.5 ${
|
||||||
}`} />
|
needsUserAnswer
|
||||||
|
? "text-blue-500/50"
|
||||||
|
: "text-muted-foreground/40"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<span className={`text-sm ${
|
<span
|
||||||
needsUserAnswer ? "text-foreground" : "text-muted-foreground"
|
className={`text-sm ${
|
||||||
}`}>
|
needsUserAnswer
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</span>
|
</span>
|
||||||
{opt.description && (
|
{opt.description && (
|
||||||
<p className="text-xs text-muted-foreground/70 mt-0.5">
|
<p className="mt-0.5 text-muted-foreground/70 text-xs">
|
||||||
{opt.description}
|
{opt.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -292,9 +366,84 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ inpu
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ExitPlanMode Widget
|
||||||
|
|
||||||
|
const ExitPlanModeWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
||||||
|
input,
|
||||||
|
result,
|
||||||
|
}) => {
|
||||||
|
const [answered, setAnswered] = useState(false);
|
||||||
|
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
|
||||||
|
const needsApproval =
|
||||||
|
!answered && !isStreaming && (!result || result.is_error);
|
||||||
|
const plan = input?.plan || input?.content || "";
|
||||||
|
|
||||||
|
const sendPlanResponse = (text: string) => {
|
||||||
|
const { sendPrompt, isStreaming } = useClaudeChatStore.getState();
|
||||||
|
if (isStreaming) return;
|
||||||
|
setAnswered(true);
|
||||||
|
sendPrompt(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`my-1.5 rounded-lg border text-sm ${
|
||||||
|
needsApproval
|
||||||
|
? "border-amber-500/40 bg-amber-500/10"
|
||||||
|
: "border-amber-500/20 bg-amber-500/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<SparklesIcon className="size-3.5 text-amber-500" />
|
||||||
|
<span className="font-medium text-amber-700 dark:text-amber-300">
|
||||||
|
{needsApproval
|
||||||
|
? "Plan needs approval"
|
||||||
|
: answered
|
||||||
|
? "Plan response sent"
|
||||||
|
: "Plan handled"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{plan && (
|
||||||
|
<div className="border-amber-500/20 border-t px-3 py-2">
|
||||||
|
<pre className="max-h-56 overflow-auto whitespace-pre-wrap text-foreground text-xs leading-relaxed">
|
||||||
|
{plan}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{needsApproval && (
|
||||||
|
<div className="flex flex-wrap gap-2 border-amber-500/20 border-t px-3 py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md bg-amber-500 px-2.5 py-1 font-medium text-background text-xs hover:bg-amber-500/90"
|
||||||
|
onClick={() =>
|
||||||
|
sendPlanResponse("Approved. Continue implementing the plan.")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md border border-amber-500/30 px-2.5 py-1 text-amber-700 text-xs hover:bg-amber-500/10 dark:text-amber-300"
|
||||||
|
onClick={() =>
|
||||||
|
sendPlanResponse(
|
||||||
|
"Revise the plan before implementing. Keep it concise and address any missing risks.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Revise
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ─── TodoWrite Widget ───
|
// ─── TodoWrite Widget ───
|
||||||
|
|
||||||
const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
|
const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
||||||
|
input,
|
||||||
|
result,
|
||||||
|
}) => {
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
const todos: any[] = Array.isArray(input?.todos) ? input.todos : [];
|
const todos: any[] = Array.isArray(input?.todos) ? input.todos : [];
|
||||||
|
|
||||||
|
|
@ -323,12 +472,14 @@ const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, res
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
Todos ({completedCount}/{todos.length})
|
Todos ({completedCount}/{todos.length})
|
||||||
</span>
|
</span>
|
||||||
{expanded
|
{expanded ? (
|
||||||
? <ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
|
<ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
|
||||||
: <ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />}
|
) : (
|
||||||
|
<ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
{expanded && todos.length > 0 && (
|
{expanded && todos.length > 0 && (
|
||||||
<div className="space-y-0.5 border-t border-border px-3 py-2">
|
<div className="space-y-0.5 border-border border-t px-3 py-2">
|
||||||
{todos.map((todo, idx) => (
|
{todos.map((todo, idx) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
|
|
@ -340,14 +491,14 @@ const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, res
|
||||||
<span
|
<span
|
||||||
className={`text-xs ${
|
className={`text-xs ${
|
||||||
todo.status === "completed"
|
todo.status === "completed"
|
||||||
? "line-through text-muted-foreground"
|
? "text-muted-foreground line-through"
|
||||||
: todo.status === "in_progress"
|
: todo.status === "in_progress"
|
||||||
? "font-medium text-foreground"
|
? "font-medium text-foreground"
|
||||||
: "text-muted-foreground"
|
: "text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{todo.status === "in_progress"
|
{todo.status === "in_progress"
|
||||||
? (todo.activeForm || todo.content)
|
? todo.activeForm || todo.content
|
||||||
: todo.content}
|
: todo.content}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -360,11 +511,11 @@ const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, res
|
||||||
|
|
||||||
// ─── Generic Widget ───
|
// ─── Generic Widget ───
|
||||||
|
|
||||||
const GenericWidget: FC<{ name: string; input: any; result?: ContentBlock }> = ({
|
const GenericWidget: FC<{
|
||||||
name,
|
name: string;
|
||||||
input,
|
input: any;
|
||||||
result,
|
result?: ContentBlock;
|
||||||
}) => {
|
}> = ({ name, input, result }) => {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -379,13 +530,15 @@ const GenericWidget: FC<{ name: string; input: any; result?: ContentBlock }> = (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{result ? "Ran" : "Running"} <code className="text-xs">{name}</code>
|
{result ? "Ran" : "Running"} <code className="text-xs">{name}</code>
|
||||||
</span>
|
</span>
|
||||||
{expanded
|
{expanded ? (
|
||||||
? <ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
|
<ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
|
||||||
: <ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />}
|
) : (
|
||||||
|
<ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
{expanded && input && (
|
{expanded && input && (
|
||||||
<div className="max-h-32 overflow-auto border-t border-border px-3 py-2">
|
<div className="max-h-32 overflow-auto border-border border-t px-3 py-2">
|
||||||
<pre className="whitespace-pre-wrap font-mono text-xs text-muted-foreground">
|
<pre className="whitespace-pre-wrap font-mono text-muted-foreground text-xs">
|
||||||
{JSON.stringify(input, null, 2)}
|
{JSON.stringify(input, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -396,29 +549,34 @@ const GenericWidget: FC<{ name: string; input: any; result?: ContentBlock }> = (
|
||||||
|
|
||||||
// ─── Thinking Widget ───
|
// ─── Thinking Widget ───
|
||||||
|
|
||||||
export const ThinkingWidget: FC<{ thinking: string; signature?: string }> = ({ thinking }) => {
|
export const ThinkingWidget: FC<{ thinking: string; signature?: string }> = ({
|
||||||
|
thinking,
|
||||||
|
}) => {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const trimmed = thinking.trim();
|
const trimmed = thinking.trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-1.5 rounded-lg border border-muted-foreground/20 bg-muted-foreground/5 overflow-hidden">
|
<div className="my-1.5 overflow-hidden rounded-lg border border-muted-foreground/20 bg-muted-foreground/5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
className="flex w-full items-center justify-between px-3 py-2 hover:bg-muted-foreground/10 transition-colors"
|
className="flex w-full items-center justify-between px-3 py-2 transition-colors hover:bg-muted-foreground/10"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative">
|
<div className="flex size-5 items-center justify-center rounded-full bg-muted-foreground/10">
|
||||||
<BotIcon className="size-4 text-muted-foreground" />
|
<BrainIcon className="size-3.5 text-muted-foreground" />
|
||||||
<SparklesIcon className="size-2.5 text-muted-foreground/70 absolute -top-1 -right-1 animate-pulse" />
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-muted-foreground italic">Thinking...</span>
|
<span className="font-medium text-muted-foreground text-sm italic">
|
||||||
|
Thinking...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRightIcon className={`size-4 text-muted-foreground transition-transform ${expanded ? "rotate-90" : ""}`} />
|
<ChevronRightIcon
|
||||||
|
className={`size-4 text-muted-foreground transition-transform ${expanded ? "rotate-90" : ""}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="border-t border-muted-foreground/20 px-3 pb-3 pt-2">
|
<div className="border-muted-foreground/20 border-t px-3 pt-2 pb-3">
|
||||||
<pre className="whitespace-pre-wrap rounded-lg bg-muted-foreground/5 p-3 font-mono text-xs text-muted-foreground italic">
|
<pre className="whitespace-pre-wrap rounded-lg bg-muted-foreground/5 p-3 font-mono text-muted-foreground text-xs italic">
|
||||||
{trimmed}
|
{trimmed}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -431,5 +589,5 @@ export const ThinkingWidget: FC<{ thinking: string; signature?: string }> = ({ t
|
||||||
|
|
||||||
function truncate(str: string, max: number): string {
|
function truncate(str: string, max: number): string {
|
||||||
if (!str) return "";
|
if (!str) return "";
|
||||||
return str.length > max ? str.slice(0, max) + "..." : str;
|
return str.length > max ? `${str.slice(0, max)}...` : str;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
310
apps/desktop/src/components/debug/debug-page.tsx
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { BugIcon, CopyIcon, TrashIcon, CheckIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
useLogStore,
|
||||||
|
getGpuRenderer,
|
||||||
|
getVisibilityLogs,
|
||||||
|
APP_VISIBILITY_RESTORED,
|
||||||
|
type LogLevel,
|
||||||
|
type SystemInfo,
|
||||||
|
} from "@/lib/debug/log-store";
|
||||||
|
import { generateBugReport } from "@/lib/debug/bug-report";
|
||||||
|
|
||||||
|
const LEVEL_COLORS: Record<LogLevel, string> = {
|
||||||
|
debug: "text-neutral-400",
|
||||||
|
info: "text-blue-500",
|
||||||
|
warn: "text-yellow-500",
|
||||||
|
error: "text-red-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Tab = "logs" | "system" | "visibility";
|
||||||
|
|
||||||
|
export function DebugPage() {
|
||||||
|
const [tab, setTab] = useState<Tab>("logs");
|
||||||
|
const [levelFilter, setLevelFilter] = useState<LogLevel | "all">("all");
|
||||||
|
const [sourceFilter, setSourceFilter] = useState("");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
|
||||||
|
const [visibilityCount, setVisibilityCount] = useState(0);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const logEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const version = useLogStore((s) => s.version);
|
||||||
|
const store = useLogStore.getState();
|
||||||
|
const entries = store.getEntries();
|
||||||
|
|
||||||
|
// Track visibility restore events
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setVisibilityCount((c) => c + 1);
|
||||||
|
window.addEventListener(APP_VISIBILITY_RESTORED, handler);
|
||||||
|
return () => window.removeEventListener(APP_VISIBILITY_RESTORED, handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch system info once
|
||||||
|
useEffect(() => {
|
||||||
|
invoke<SystemInfo>("get_system_info")
|
||||||
|
.then(setSystemInfo)
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-scroll logs only if already scrolled to bottom.
|
||||||
|
// Use rAF to wait for the DOM to update after React re-render.
|
||||||
|
const wasAtBottomRef = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab !== "logs") return;
|
||||||
|
const container = logContainerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
// Check before new content is painted
|
||||||
|
const gap =
|
||||||
|
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
|
wasAtBottomRef.current = gap < 40;
|
||||||
|
}); // runs every render, before paint
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab !== "logs" || !wasAtBottomRef.current) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
logEndRef.current?.scrollIntoView({ behavior: "instant" });
|
||||||
|
});
|
||||||
|
}, [version, tab]);
|
||||||
|
|
||||||
|
const filteredEntries = store.getFilteredLogs(
|
||||||
|
levelFilter === "all" && !sourceFilter && !search
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
level: levelFilter === "all" ? undefined : levelFilter,
|
||||||
|
source: sourceFilter || undefined,
|
||||||
|
search: search || undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const sources = [...new Set(entries.map((e) => e.source))].sort();
|
||||||
|
|
||||||
|
const handleCopyReport = useCallback(async () => {
|
||||||
|
const report = await generateBugReport();
|
||||||
|
await navigator.clipboard.writeText(report);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatTime = (ts: number) => {
|
||||||
|
const d = new Date(ts);
|
||||||
|
return (
|
||||||
|
d.toLocaleTimeString("en-US", { hour12: false }) +
|
||||||
|
"." +
|
||||||
|
String(d.getMilliseconds()).padStart(3, "0")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BugIcon className="size-4" />
|
||||||
|
<h1 className="font-semibold text-sm">Debug</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyReport}
|
||||||
|
className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1 font-medium text-primary-foreground text-xs hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<CopyIcon className="size-3.5" />
|
||||||
|
)}
|
||||||
|
{copied ? "Copied!" : "Copy Bug Report"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="flex gap-1 border-b px-4">
|
||||||
|
{(["logs", "system", "visibility"] as Tab[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className={`border-b-2 px-3 py-1.5 font-medium text-xs capitalize transition-colors ${
|
||||||
|
tab === t
|
||||||
|
? "border-primary text-foreground"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
<div className="flex-1 overflow-hidden px-4 py-2">
|
||||||
|
{tab === "logs" && (
|
||||||
|
<div className="flex h-full flex-col gap-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={levelFilter}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLevelFilter(e.target.value as LogLevel | "all")
|
||||||
|
}
|
||||||
|
className="rounded border bg-background px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<option value="all">All levels</option>
|
||||||
|
<option value="debug">Debug</option>
|
||||||
|
<option value="info">Info</option>
|
||||||
|
<option value="warn">Warn</option>
|
||||||
|
<option value="error">Error</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={sourceFilter}
|
||||||
|
onChange={(e) => setSourceFilter(e.target.value)}
|
||||||
|
className="rounded border bg-background px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<option value="">All sources</option>
|
||||||
|
{sources.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="flex-1 rounded border bg-background px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={store.clear}
|
||||||
|
title="Clear logs"
|
||||||
|
className="rounded border p-1 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<TrashIcon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={logContainerRef}
|
||||||
|
className="flex-1 overflow-auto rounded border bg-muted/30 p-2 font-mono text-[11px]"
|
||||||
|
>
|
||||||
|
{filteredEntries.length === 0 && (
|
||||||
|
<p className="py-4 text-center text-muted-foreground">
|
||||||
|
No log entries
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{filteredEntries.map((entry, i) => (
|
||||||
|
<div key={i} className="flex gap-2 py-0.5 hover:bg-muted/50">
|
||||||
|
<span className="shrink-0 text-muted-foreground">
|
||||||
|
{formatTime(entry.timestamp)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`w-10 shrink-0 font-semibold uppercase ${LEVEL_COLORS[entry.level]}`}
|
||||||
|
>
|
||||||
|
{entry.level}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-muted-foreground">
|
||||||
|
[{entry.source}]
|
||||||
|
</span>
|
||||||
|
<span className="break-all text-foreground">
|
||||||
|
{entry.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={logEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{entries.length} entries ({filteredEntries.length} shown)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "system" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="font-semibold text-muted-foreground text-xs uppercase">
|
||||||
|
System Information
|
||||||
|
</h3>
|
||||||
|
{systemInfo ? (
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<Row label="OS" value={systemInfo.os} />
|
||||||
|
<Row label="OS Version" value={systemInfo.os_version} />
|
||||||
|
<Row label="Architecture" value={systemInfo.arch} />
|
||||||
|
<Row label="App Version" value={systemInfo.app_version} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm">Loading...</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3 className="pt-4 font-semibold text-muted-foreground text-xs uppercase">
|
||||||
|
Browser / WebView
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<Row label="User Agent" value={navigator.userAgent} />
|
||||||
|
<Row
|
||||||
|
label="Device Pixel Ratio"
|
||||||
|
value={String(window.devicePixelRatio)}
|
||||||
|
/>
|
||||||
|
<Row label="GPU Renderer" value={getGpuRenderer()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "visibility" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="font-semibold text-muted-foreground text-xs uppercase">
|
||||||
|
Visibility State
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<Row
|
||||||
|
label="Current State"
|
||||||
|
value={document.visibilityState}
|
||||||
|
valueClass={
|
||||||
|
document.visibilityState === "visible"
|
||||||
|
? "text-green-500"
|
||||||
|
: "text-yellow-500"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label="Window Focused"
|
||||||
|
value={document.hasFocus() ? "Yes" : "No"}
|
||||||
|
/>
|
||||||
|
<Row label="Restore Events" value={String(visibilityCount)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="pt-4 font-semibold text-muted-foreground text-xs uppercase">
|
||||||
|
Recent Visibility Logs
|
||||||
|
</h3>
|
||||||
|
<div className="max-h-48 overflow-auto rounded border bg-muted/30 p-2 font-mono text-[11px]">
|
||||||
|
{getVisibilityLogs().map((entry, i) => (
|
||||||
|
<div key={i} className="py-0.5">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{formatTime(entry.timestamp)}
|
||||||
|
</span>{" "}
|
||||||
|
<span>{entry.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
valueClass,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
valueClass?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="w-32 shrink-0 text-muted-foreground">{label}:</span>
|
||||||
|
<span className={`break-all ${valueClass ?? ""}`}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
509
apps/desktop/src/components/environment-onboarding.tsx
Normal file
|
|
@ -0,0 +1,509 @@
|
||||||
|
import {
|
||||||
|
type ComponentType,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { open as shellOpen } from "@tauri-apps/plugin-shell";
|
||||||
|
import {
|
||||||
|
AlertCircleIcon,
|
||||||
|
CheckCircle2Icon,
|
||||||
|
CircleIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
FlaskConicalIcon,
|
||||||
|
GitBranchIcon,
|
||||||
|
KeyRoundIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
TerminalIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { ClaudeSetup } from "@/components/claude-setup";
|
||||||
|
import { useClaudeSetupStore } from "@/stores/claude-setup-store";
|
||||||
|
import { useUvSetupStore } from "@/stores/uv-setup-store";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type SetupItemState = "ready" | "loading" | "blocked" | "error";
|
||||||
|
|
||||||
|
interface SkillsStatus {
|
||||||
|
installed: boolean;
|
||||||
|
skill_count: number;
|
||||||
|
location: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnvironmentOnboarding() {
|
||||||
|
const [initialCheckComplete, setInitialCheckComplete] = useState(false);
|
||||||
|
const keepOpenDuringCheckRef = useRef(false);
|
||||||
|
const [hasOpenedForSetup, setHasOpenedForSetup] = useState(false);
|
||||||
|
const [completedDismissed, setCompletedDismissed] = useState(false);
|
||||||
|
const [providerDialogOpen, setProviderDialogOpen] = useState(false);
|
||||||
|
const [skillsStatus, setSkillsStatus] = useState<SkillsStatus | null>(null);
|
||||||
|
const [skillsChecking, setSkillsChecking] = useState(true);
|
||||||
|
const [skillsError, setSkillsError] = useState<string | null>(null);
|
||||||
|
const [skillsDialogOpen, setSkillsDialogOpen] = useState(false);
|
||||||
|
const [SkillsOnboardingComponent, setSkillsOnboardingComponent] =
|
||||||
|
useState<ComponentType<{ onClose: () => void }> | null>(null);
|
||||||
|
|
||||||
|
const claudeStatus = useClaudeSetupStore((s) => s.status);
|
||||||
|
const claudeVersion = useClaudeSetupStore((s) => s.version);
|
||||||
|
const claudeError = useClaudeSetupStore((s) => s.error);
|
||||||
|
const isClaudeInstalling = useClaudeSetupStore((s) => s.isInstalling);
|
||||||
|
const providerKind = useClaudeSetupStore((s) => s.providerKind);
|
||||||
|
const claudeProviderConfigured = useClaudeSetupStore(
|
||||||
|
(s) => s.claudeProviderConfigured,
|
||||||
|
);
|
||||||
|
const openAiCredentials = useClaudeSetupStore((s) => s.openAiCredentials);
|
||||||
|
const checkClaudeStatus = useClaudeSetupStore((s) => s.checkStatus);
|
||||||
|
const installClaude = useClaudeSetupStore((s) => s.install);
|
||||||
|
|
||||||
|
const uvStatus = useUvSetupStore((s) => s.status);
|
||||||
|
const uvVersion = useUvSetupStore((s) => s.version);
|
||||||
|
const uvError = useUvSetupStore((s) => s.error);
|
||||||
|
const isUvInstalling = useUvSetupStore((s) => s.isInstalling);
|
||||||
|
const checkUvStatus = useUvSetupStore((s) => s.checkStatus);
|
||||||
|
const installUv = useUvSetupStore((s) => s.install);
|
||||||
|
const finishUvInstall = useUvSetupStore((s) => s._finishInstall);
|
||||||
|
|
||||||
|
const checkSkillsStatus = useCallback(async () => {
|
||||||
|
setSkillsChecking(true);
|
||||||
|
setSkillsError(null);
|
||||||
|
try {
|
||||||
|
const status = await invoke<SkillsStatus>("check_skills_installed", {
|
||||||
|
projectPath: null,
|
||||||
|
});
|
||||||
|
setSkillsStatus(status);
|
||||||
|
} catch (err) {
|
||||||
|
setSkillsStatus(null);
|
||||||
|
setSkillsError(String(err));
|
||||||
|
} finally {
|
||||||
|
setSkillsChecking(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
Promise.allSettled([
|
||||||
|
checkClaudeStatus(),
|
||||||
|
checkUvStatus(),
|
||||||
|
checkSkillsStatus(),
|
||||||
|
]).finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setInitialCheckComplete(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [checkClaudeStatus, checkSkillsStatus, checkUvStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unlisten = listen<boolean>("uv-install-complete", (event) => {
|
||||||
|
finishUvInstall(event.payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlisten.then((fn) => fn());
|
||||||
|
};
|
||||||
|
}, [finishUvInstall]);
|
||||||
|
|
||||||
|
const isClaudeInstalled =
|
||||||
|
claudeStatus === "ready" || claudeStatus === "not-authenticated";
|
||||||
|
const isClaudeReady = claudeStatus === "ready";
|
||||||
|
const isUvReady = uvStatus === "ready";
|
||||||
|
const isSkillsReady = !!skillsStatus?.installed;
|
||||||
|
const claudeNeedsAttention =
|
||||||
|
isClaudeInstalling || (claudeStatus !== "checking" && !isClaudeReady);
|
||||||
|
const uvNeedsAttention =
|
||||||
|
isUvInstalling || (uvStatus !== "checking" && !isUvReady);
|
||||||
|
const skillsNeedsAttention =
|
||||||
|
!skillsChecking && (!isSkillsReady || !!skillsError);
|
||||||
|
const needsAttention =
|
||||||
|
claudeNeedsAttention || uvNeedsAttention || skillsNeedsAttention;
|
||||||
|
const isCheckingSetup =
|
||||||
|
claudeStatus === "checking" || uvStatus === "checking" || skillsChecking;
|
||||||
|
const setupComplete =
|
||||||
|
initialCheckComplete && !needsAttention && !isCheckingSetup;
|
||||||
|
const shouldShow =
|
||||||
|
initialCheckComplete &&
|
||||||
|
!completedDismissed &&
|
||||||
|
(needsAttention ||
|
||||||
|
(isCheckingSetup && keepOpenDuringCheckRef.current) ||
|
||||||
|
hasOpenedForSetup);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (needsAttention) {
|
||||||
|
keepOpenDuringCheckRef.current = true;
|
||||||
|
setHasOpenedForSetup(true);
|
||||||
|
setCompletedDismissed(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCheckingSetup) {
|
||||||
|
keepOpenDuringCheckRef.current = false;
|
||||||
|
}
|
||||||
|
}, [isCheckingSetup, needsAttention]);
|
||||||
|
|
||||||
|
const handleDone = () => {
|
||||||
|
if (!setupComplete) return;
|
||||||
|
keepOpenDuringCheckRef.current = false;
|
||||||
|
setHasOpenedForSetup(false);
|
||||||
|
setCompletedDismissed(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSkillsDialog = () => {
|
||||||
|
setSkillsDialogOpen(true);
|
||||||
|
if (!SkillsOnboardingComponent) {
|
||||||
|
import(
|
||||||
|
"@/components/scientific-skills/scientific-skills-onboarding"
|
||||||
|
).then((mod) =>
|
||||||
|
setSkillsOnboardingComponent(() => mod.ScientificSkillsOnboarding),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerDetail = useMemo(() => {
|
||||||
|
if (!isClaudeInstalled) {
|
||||||
|
return "Install Claude Code before adding a provider";
|
||||||
|
}
|
||||||
|
if (!isClaudeReady) {
|
||||||
|
return "Add an API key or sign in";
|
||||||
|
}
|
||||||
|
const openAiProviderCount = Math.max(
|
||||||
|
openAiCredentials.length,
|
||||||
|
providerKind === "openai-compatible" ? 1 : 0,
|
||||||
|
);
|
||||||
|
const includesClaudeProvider =
|
||||||
|
claudeProviderConfigured || providerKind === "claude-code";
|
||||||
|
const count = openAiProviderCount + (includesClaudeProvider ? 1 : 0);
|
||||||
|
return `${count} provider${count === 1 ? "" : "s"} configured`;
|
||||||
|
}, [
|
||||||
|
claudeProviderConfigured,
|
||||||
|
isClaudeInstalled,
|
||||||
|
isClaudeReady,
|
||||||
|
openAiCredentials.length,
|
||||||
|
providerKind,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={shouldShow} onOpenChange={() => undefined}>
|
||||||
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||||
|
onInteractOutside={(event) => event.preventDefault()}
|
||||||
|
className="w-[min(29rem,calc(100vw-2rem))] gap-0 overflow-hidden rounded-2xl border-border/70 p-0 shadow-xl sm:max-w-none"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center px-6 pt-6 pb-4 text-center">
|
||||||
|
<img
|
||||||
|
src="/icon-192.png"
|
||||||
|
alt="ClaudePrism"
|
||||||
|
className="size-14 object-contain"
|
||||||
|
/>
|
||||||
|
<DialogHeader className="mt-3 items-center gap-1.5 text-center">
|
||||||
|
<DialogTitle className="font-semibold text-xl">
|
||||||
|
ClaudePrism
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="max-w-sm text-sm leading-relaxed">
|
||||||
|
Set up the local tools and model provider required before
|
||||||
|
entering the workspace.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4.5 pb-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<SetupItem
|
||||||
|
state={
|
||||||
|
isClaudeInstalling || claudeStatus === "checking"
|
||||||
|
? "loading"
|
||||||
|
: claudeStatus === "error"
|
||||||
|
? "error"
|
||||||
|
: isClaudeInstalled
|
||||||
|
? "ready"
|
||||||
|
: "blocked"
|
||||||
|
}
|
||||||
|
icon={TerminalIcon}
|
||||||
|
title="Claude Code"
|
||||||
|
detail={
|
||||||
|
isClaudeInstalling
|
||||||
|
? "Installing..."
|
||||||
|
: claudeStatus === "checking"
|
||||||
|
? "Checking..."
|
||||||
|
: claudeStatus === "missing-git"
|
||||||
|
? "Git for Windows is required first"
|
||||||
|
: claudeStatus === "not-installed"
|
||||||
|
? "Required for AI writing"
|
||||||
|
: claudeStatus === "error"
|
||||||
|
? claudeError || "Installation needs attention"
|
||||||
|
: claudeVersion
|
||||||
|
? `Installed ${claudeVersion}`
|
||||||
|
: "Installed"
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
claudeStatus === "missing-git"
|
||||||
|
? {
|
||||||
|
label: "Git",
|
||||||
|
icon: GitBranchIcon,
|
||||||
|
onClick: () => {
|
||||||
|
shellOpen("https://git-scm.com/downloads/win");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: claudeStatus === "not-installed" ||
|
||||||
|
claudeStatus === "error"
|
||||||
|
? {
|
||||||
|
label: isClaudeInstalling ? "Installing" : "Install",
|
||||||
|
icon: isClaudeInstalling ? Loader2Icon : DownloadIcon,
|
||||||
|
loading: isClaudeInstalling,
|
||||||
|
onClick: installClaude,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: "Check",
|
||||||
|
icon: RefreshCwIcon,
|
||||||
|
onClick: checkClaudeStatus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SetupItem
|
||||||
|
state={
|
||||||
|
isUvInstalling || uvStatus === "checking"
|
||||||
|
? "loading"
|
||||||
|
: uvStatus === "error"
|
||||||
|
? "error"
|
||||||
|
: isUvReady
|
||||||
|
? "ready"
|
||||||
|
: "blocked"
|
||||||
|
}
|
||||||
|
icon={TerminalIcon}
|
||||||
|
title="Python (uv)"
|
||||||
|
detail={
|
||||||
|
isUvInstalling
|
||||||
|
? "Installing..."
|
||||||
|
: uvStatus === "checking"
|
||||||
|
? "Checking..."
|
||||||
|
: uvStatus === "not-installed"
|
||||||
|
? "Required for Python workflows"
|
||||||
|
: uvStatus === "error"
|
||||||
|
? uvError || "Installation needs attention"
|
||||||
|
: uvVersion || "Installed"
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
uvStatus === "not-installed" || uvStatus === "error"
|
||||||
|
? {
|
||||||
|
label: isUvInstalling ? "Installing" : "Install",
|
||||||
|
icon: isUvInstalling ? Loader2Icon : DownloadIcon,
|
||||||
|
loading: isUvInstalling,
|
||||||
|
onClick: installUv,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: "Check",
|
||||||
|
icon: RefreshCwIcon,
|
||||||
|
onClick: checkUvStatus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SetupItem
|
||||||
|
state={
|
||||||
|
claudeStatus === "checking"
|
||||||
|
? "loading"
|
||||||
|
: claudeStatus === "error"
|
||||||
|
? "error"
|
||||||
|
: isClaudeReady
|
||||||
|
? "ready"
|
||||||
|
: "blocked"
|
||||||
|
}
|
||||||
|
icon={KeyRoundIcon}
|
||||||
|
title="AI Provider"
|
||||||
|
detail={providerDetail}
|
||||||
|
action={
|
||||||
|
isClaudeInstalled
|
||||||
|
? {
|
||||||
|
label: isClaudeReady ? "Manage" : "Configure",
|
||||||
|
icon: KeyRoundIcon,
|
||||||
|
onClick: () => setProviderDialogOpen(true),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: "Locked",
|
||||||
|
icon: KeyRoundIcon,
|
||||||
|
disabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SetupItem
|
||||||
|
state={
|
||||||
|
skillsChecking
|
||||||
|
? "loading"
|
||||||
|
: skillsError
|
||||||
|
? "error"
|
||||||
|
: isSkillsReady
|
||||||
|
? "ready"
|
||||||
|
: "blocked"
|
||||||
|
}
|
||||||
|
icon={FlaskConicalIcon}
|
||||||
|
title="Scientific Skills"
|
||||||
|
detail={
|
||||||
|
skillsChecking
|
||||||
|
? "Checking..."
|
||||||
|
: skillsError
|
||||||
|
? skillsError
|
||||||
|
: isSkillsReady
|
||||||
|
? `${skillsStatus?.skill_count ?? 0} skills installed`
|
||||||
|
: "Required for scientific writing"
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
skillsError
|
||||||
|
? {
|
||||||
|
label: "Check",
|
||||||
|
icon: RefreshCwIcon,
|
||||||
|
onClick: checkSkillsStatus,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: isSkillsReady ? "Manage" : "Install",
|
||||||
|
icon: isSkillsReady ? FlaskConicalIcon : DownloadIcon,
|
||||||
|
onClick: openSkillsDialog,
|
||||||
|
disabled: skillsChecking,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center px-6 pt-1 pb-4">
|
||||||
|
<Button
|
||||||
|
disabled={!setupComplete}
|
||||||
|
className="h-10 min-w-28 justify-center rounded-full px-7"
|
||||||
|
onClick={handleDone}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={providerDialogOpen} onOpenChange={setProviderDialogOpen}>
|
||||||
|
<DialogContent className="max-h-[85vh] w-[min(42rem,calc(100vw-2rem))] overflow-y-auto overflow-x-hidden sm:max-w-none">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add AI Provider</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure Anthropic or another model provider for this project.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<ClaudeSetup
|
||||||
|
variant="provider-dialog"
|
||||||
|
onCancel={() => setProviderDialogOpen(false)}
|
||||||
|
onSaved={() => {
|
||||||
|
setProviderDialogOpen(false);
|
||||||
|
checkClaudeStatus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{skillsDialogOpen && SkillsOnboardingComponent && (
|
||||||
|
<SkillsOnboardingComponent
|
||||||
|
onClose={() => {
|
||||||
|
setSkillsDialogOpen(false);
|
||||||
|
checkSkillsStatus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SetupItem({
|
||||||
|
state,
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
detail,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
state: SetupItemState;
|
||||||
|
icon: typeof TerminalIcon;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
action?: {
|
||||||
|
label: string;
|
||||||
|
icon: typeof TerminalIcon;
|
||||||
|
onClick?: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const ActionIcon = action?.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-[3.75rem] w-full max-w-full grid-cols-[2.25rem_minmax(0,1fr)_auto] items-center gap-2.5 overflow-hidden rounded-xl bg-muted/35 px-3 py-2.5 transition-colors hover:bg-muted/50">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex size-9 shrink-0 items-center justify-center",
|
||||||
|
state === "ready" && "text-green-600 dark:text-green-400",
|
||||||
|
state === "loading" && "text-muted-foreground",
|
||||||
|
state === "blocked" && "text-muted-foreground/75",
|
||||||
|
state === "error" && "text-destructive",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{state === "ready" ? (
|
||||||
|
<CheckCircle2Icon className="size-6" />
|
||||||
|
) : state === "loading" ? (
|
||||||
|
<Loader2Icon className="size-5 animate-spin" />
|
||||||
|
) : state === "error" ? (
|
||||||
|
<AlertCircleIcon className="size-5" />
|
||||||
|
) : (
|
||||||
|
<Icon className="size-5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate font-semibold text-sm">{title}</span>
|
||||||
|
{state === "blocked" && (
|
||||||
|
<CircleIcon className="size-2.5 shrink-0 text-muted-foreground/50" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"mt-0.5 truncate text-sm",
|
||||||
|
state === "error" ? "text-destructive" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
title={detail}
|
||||||
|
>
|
||||||
|
{detail}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{action && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={state === "ready" ? "outline" : "default"}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 max-w-24 shrink-0 gap-1.5 rounded-lg px-2.5 text-sm"
|
||||||
|
onClick={action.onClick}
|
||||||
|
disabled={action.disabled || action.loading}
|
||||||
|
>
|
||||||
|
{ActionIcon && (
|
||||||
|
<ActionIcon
|
||||||
|
className={cn("size-3.5", action.loading && "animate-spin")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,15 +3,15 @@ import type { FallbackProps } from "react-error-boundary";
|
||||||
export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen items-center justify-center bg-background p-8">
|
<div className="flex h-screen w-screen items-center justify-center bg-background p-8">
|
||||||
<div className="max-w-2xl w-full space-y-4">
|
<div className="w-full max-w-2xl space-y-4">
|
||||||
<h1 className="text-2xl font-bold text-destructive">
|
<h1 className="font-bold text-2xl text-destructive">
|
||||||
Something went wrong
|
Something went wrong
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
An unexpected error occurred. You can try again or reload the app.
|
An unexpected error occurred. You can try again or reload the app.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<pre className="max-h-64 overflow-auto rounded-md border bg-muted p-4 text-xs whitespace-pre-wrap">
|
<pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded-md border bg-muted p-4 text-xs">
|
||||||
{error instanceof Error
|
{error instanceof Error
|
||||||
? `${error.message}${error.stack ? `\n\n${error.stack}` : ""}`
|
? `${error.message}${error.stack ? `\n\n${error.stack}` : ""}`
|
||||||
: String(error)}
|
: String(error)}
|
||||||
|
|
@ -21,14 +21,14 @@ export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={resetErrorBoundary}
|
onClick={resetErrorBoundary}
|
||||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
className="rounded-md bg-primary px-4 py-2 font-medium text-primary-foreground text-sm hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
Try again
|
Try again
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
className="rounded-md border px-4 py-2 text-sm font-medium hover:bg-accent"
|
className="rounded-md border px-4 py-2 font-medium text-sm hover:bg-accent"
|
||||||
>
|
>
|
||||||
Reload
|
Reload
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
38
apps/desktop/src/components/model-capability-badges.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { EyeIcon, MessageCircleIcon } from "lucide-react";
|
||||||
|
import { getModelCapabilities } from "@/lib/model-capabilities";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ModelCapabilityBadgesProps {
|
||||||
|
label?: string | null;
|
||||||
|
baseUrl?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelCapabilityBadges({
|
||||||
|
label,
|
||||||
|
baseUrl,
|
||||||
|
model,
|
||||||
|
className,
|
||||||
|
}: ModelCapabilityBadgesProps) {
|
||||||
|
const capabilities = getModelCapabilities({ label, baseUrl, model });
|
||||||
|
const badgeClass =
|
||||||
|
"inline-flex h-5 items-center justify-center rounded-md border border-neutral-300 bg-white text-neutral-600 dark:border-neutral-600 dark:bg-neutral-950 dark:text-neutral-300";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("inline-flex shrink-0 items-center gap-0.5", className)}
|
||||||
|
>
|
||||||
|
{capabilities.chat && (
|
||||||
|
<span className={cn(badgeClass, "min-w-5 px-1")} title="Chat model">
|
||||||
|
<MessageCircleIcon className="size-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{capabilities.vision && (
|
||||||
|
<span className={cn(badgeClass, "min-w-5 px-1")} title="Vision input">
|
||||||
|
<EyeIcon className="size-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { mkdir, writeTextFile } from "@tauri-apps/plugin-fs";
|
import { mkdir, writeTextFile } from "@tauri-apps/plugin-fs";
|
||||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||||
import { homeDir } from "@tauri-apps/api/path";
|
import { homeDir } from "@tauri-apps/api/path";
|
||||||
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
FolderOpenIcon,
|
FolderOpenIcon,
|
||||||
|
|
@ -22,21 +23,21 @@ import { useProjectStore } from "@/stores/project-store";
|
||||||
import { useDocumentStore } from "@/stores/document-store";
|
import { useDocumentStore } from "@/stores/document-store";
|
||||||
import { useClaudeChatStore } from "@/stores/claude-chat-store";
|
import { useClaudeChatStore } from "@/stores/claude-chat-store";
|
||||||
import { exists, join } from "@/lib/tauri/fs";
|
import { exists, join } from "@/lib/tauri/fs";
|
||||||
import { getTemplateById, getTemplateSkeleton, BIB_TEMPLATE } from "@/lib/template-registry";
|
import {
|
||||||
|
getTemplateById,
|
||||||
|
getTemplateSkeleton,
|
||||||
|
BIB_TEMPLATE,
|
||||||
|
} from "@/lib/template-registry";
|
||||||
import { TemplateGallery } from "@/components/template-gallery";
|
import { TemplateGallery } from "@/components/template-gallery";
|
||||||
import { DEFAULT_CLAUDE_MD } from "@/lib/default-claude-md";
|
import { DEFAULT_CLAUDE_MD } from "@/lib/default-claude-md";
|
||||||
|
import {
|
||||||
|
buildReferenceFilesSection,
|
||||||
|
importReferenceFiles,
|
||||||
|
} from "@/lib/project-attachments";
|
||||||
|
import { getProjectNameError, normalizeProjectName } from "@/lib/project-name";
|
||||||
|
|
||||||
// ─── Helpers ───
|
// ─── Helpers ───
|
||||||
|
|
||||||
function randomProjectName(): string {
|
|
||||||
const adjectives = ["swift", "bright", "calm", "bold", "keen", "warm", "pure", "vast", "deep", "fair"];
|
|
||||||
const nouns = ["paper", "draft", "thesis", "note", "study", "essay", "report", "brief", "folio", "opus"];
|
|
||||||
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
|
|
||||||
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
|
||||||
const id = Math.random().toString(36).slice(2, 6);
|
|
||||||
return `${adj}-${noun}-${id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Wizard Component ───
|
// ─── Wizard Component ───
|
||||||
|
|
||||||
export type CreationMode = "template" | "scratch";
|
export type CreationMode = "template" | "scratch";
|
||||||
|
|
@ -52,8 +53,13 @@ export function ProjectWizard({ mode, onBack }: ProjectWizardProps) {
|
||||||
if (mode === "template") {
|
if (mode === "template") {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-background">
|
<div className="flex h-full flex-col bg-background">
|
||||||
<div className="flex shrink-0 items-center gap-3 border-b border-border/60 px-4 pt-[var(--titlebar-height)] h-[calc(48px+var(--titlebar-height))]">
|
<div className="flex h-[calc(48px+var(--titlebar-height))] shrink-0 items-center gap-3 border-border/60 border-b px-4 pt-[var(--titlebar-height)]">
|
||||||
<Button variant="ghost" size="icon" className="size-7 rounded-lg" onClick={onBack}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 rounded-lg"
|
||||||
|
onClick={onBack}
|
||||||
|
>
|
||||||
<ArrowLeftIcon className="size-4" />
|
<ArrowLeftIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="font-semibold text-sm">Choose a Template</span>
|
<span className="font-semibold text-sm">Choose a Template</span>
|
||||||
|
|
@ -75,12 +81,14 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
const [purpose, setPurpose] = useState("");
|
const [purpose, setPurpose] = useState("");
|
||||||
const [attachments, setAttachments] = useState<string[]>([]);
|
const [attachments, setAttachments] = useState<string[]>([]);
|
||||||
const [projectFolder, setProjectFolder] = useState<string | null>(null);
|
const [projectFolder, setProjectFolder] = useState<string | null>(null);
|
||||||
const [projectName, setProjectName] = useState(randomProjectName);
|
const [projectName, setProjectName] = useState("");
|
||||||
|
const [projectNameError, setProjectNameError] = useState("");
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [refFilesOpen, setRefFilesOpen] = useState(false);
|
const [refFilesOpen, setRefFilesOpen] = useState(false);
|
||||||
const [locationOpen, setLocationOpen] = useState(false);
|
const [locationOpen, setLocationOpen] = useState(false);
|
||||||
|
|
||||||
|
const projectNameRef = useRef<HTMLInputElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const addRecentProject = useProjectStore((s) => s.addRecentProject);
|
const addRecentProject = useProjectStore((s) => s.addRecentProject);
|
||||||
|
|
@ -91,7 +99,7 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
const template = getTemplateById("blank")!;
|
const template = getTemplateById("blank")!;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => textareaRef.current?.focus(), 100);
|
const timer = setTimeout(() => projectNameRef.current?.focus(), 100);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -100,15 +108,24 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
if (lastProjectFolder) {
|
if (lastProjectFolder) {
|
||||||
setProjectFolder(lastProjectFolder);
|
setProjectFolder(lastProjectFolder);
|
||||||
} else {
|
} else {
|
||||||
homeDir().then((home) => join(home, "Documents", "ClaudePrism")).then((dir) => {
|
homeDir()
|
||||||
mkdir(dir, { recursive: true }).catch(() => {});
|
.then((home) => join(home, "Documents", "ClaudePrism"))
|
||||||
setProjectFolder(dir);
|
.then(async (dir) => {
|
||||||
}).catch(() => {});
|
await mkdir(dir, { recursive: true }).catch(() => {});
|
||||||
|
setProjectFolder(dir);
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
console.warn("Failed to resolve default project folder:", err),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const handleChooseFolder = useCallback(async () => {
|
const handleChooseFolder = useCallback(async () => {
|
||||||
const selected = await open({ directory: true, multiple: false, title: "Choose Location for New Project" });
|
const selected = await open({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
title: "Choose Location for New Project",
|
||||||
|
});
|
||||||
if (selected) {
|
if (selected) {
|
||||||
setProjectFolder(selected);
|
setProjectFolder(selected);
|
||||||
setLastProjectFolder(selected);
|
setLastProjectFolder(selected);
|
||||||
|
|
@ -119,14 +136,13 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
const selected = await open({
|
const selected = await open({
|
||||||
multiple: true,
|
multiple: true,
|
||||||
title: "Add Reference Files",
|
title: "Add Reference Files",
|
||||||
filters: [{
|
|
||||||
name: "Documents & Images",
|
|
||||||
extensions: ["pdf", "tex", "bib", "txt", "md", "png", "jpg", "jpeg", "gif", "svg", "csv", "tsv", "json"],
|
|
||||||
}],
|
|
||||||
});
|
});
|
||||||
if (selected) {
|
if (selected) {
|
||||||
const paths = Array.isArray(selected) ? selected : [selected];
|
const paths = Array.isArray(selected) ? selected : [selected];
|
||||||
setAttachments((prev) => [...prev, ...paths.filter((p) => !prev.includes(p))]);
|
setAttachments((prev) => [
|
||||||
|
...prev,
|
||||||
|
...paths.filter((p) => !prev.includes(p)),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -150,25 +166,43 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
const paths = (event.payload as { paths: string[] }).paths;
|
const paths = (event.payload as { paths: string[] }).paths;
|
||||||
if (paths?.length > 0) {
|
if (paths?.length > 0) {
|
||||||
setAttachments((prev) => [...prev, ...paths.filter((p) => !prev.includes(p))]);
|
setAttachments((prev) => [
|
||||||
|
...prev,
|
||||||
|
...paths.filter((p) => !prev.includes(p)),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
} else if (type === "leave") {
|
} else if (type === "leave") {
|
||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then((fn) => { if (cancelled) fn(); else unlisten = fn; })
|
.then((fn) => {
|
||||||
|
if (cancelled) fn();
|
||||||
|
else unlisten = fn;
|
||||||
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
return () => { cancelled = true; unlisten?.(); };
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unlisten?.();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!template || !projectFolder || !projectName.trim()) return;
|
const name = normalizeProjectName(projectName);
|
||||||
|
const nameError = getProjectNameError(projectName);
|
||||||
|
if (!template || !projectFolder || nameError) {
|
||||||
|
setProjectNameError(nameError ?? "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const projectPath = await join(projectFolder, projectName.trim());
|
const projectPath = await join(projectFolder, name);
|
||||||
await mkdir(projectPath, { recursive: true }).catch(() => {});
|
if (await exists(projectPath)) {
|
||||||
|
setProjectNameError("A folder with this name already exists here");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await mkdir(projectPath, { recursive: true });
|
||||||
|
|
||||||
// Create CLAUDE.md for Claude Code context
|
// Create CLAUDE.md for Claude Code context
|
||||||
const claudeMdPath = await join(projectPath, "CLAUDE.md");
|
const claudeMdPath = await join(projectPath, "CLAUDE.md");
|
||||||
|
|
@ -191,16 +225,13 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachments.length > 0) {
|
const referenceFiles =
|
||||||
const attachmentsDir = await join(projectPath, "attachments");
|
attachments.length > 0
|
||||||
await mkdir(attachmentsDir, { recursive: true }).catch(() => {});
|
? await importReferenceFiles(projectPath, attachments)
|
||||||
}
|
: [];
|
||||||
|
|
||||||
if (purpose.trim()) {
|
if (purpose.trim()) {
|
||||||
const attachmentNames = attachments.map((p) => p.split("/").pop()).filter(Boolean);
|
const attachmentSection = buildReferenceFilesSection(referenceFiles);
|
||||||
const attachmentSection = attachmentNames.length > 0
|
|
||||||
? `\n### Reference Files\n${attachmentNames.map((n) => `- \`${n}\``).join("\n")}\n\nPlease review them and incorporate relevant information.\n`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const prompt = [
|
const prompt = [
|
||||||
`## New ${template.name} Project`,
|
`## New ${template.name} Project`,
|
||||||
|
|
@ -226,24 +257,30 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
setLastProjectFolder(projectFolder);
|
setLastProjectFolder(projectFolder);
|
||||||
addRecentProject(projectPath);
|
addRecentProject(projectPath);
|
||||||
await openProject(projectPath);
|
await openProject(projectPath);
|
||||||
|
|
||||||
if (attachments.length > 0) {
|
|
||||||
await useDocumentStore.getState().importFiles(attachments, "attachments");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to create project:", err);
|
console.error("Failed to create project:", err);
|
||||||
|
toast.error("Failed to create project", {
|
||||||
|
description: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const canCreate = template && projectFolder && projectName.trim();
|
const canCreate = Boolean(
|
||||||
|
template && projectFolder && !getProjectNameError(projectName),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-background">
|
<div className="flex h-full flex-col bg-background">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex shrink-0 items-center gap-3 border-b border-border/60 px-4 pt-[var(--titlebar-height)] h-[calc(48px+var(--titlebar-height))]">
|
<div className="flex h-[calc(48px+var(--titlebar-height))] shrink-0 items-center gap-3 border-border/60 border-b px-4 pt-[var(--titlebar-height)]">
|
||||||
<Button variant="ghost" size="icon" className="size-7 rounded-lg" onClick={onBack}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 rounded-lg"
|
||||||
|
onClick={onBack}
|
||||||
|
>
|
||||||
<ArrowLeftIcon className="size-4" />
|
<ArrowLeftIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="font-semibold text-sm">New Document</span>
|
<span className="font-semibold text-sm">New Document</span>
|
||||||
|
|
@ -252,12 +289,41 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="mx-auto w-full max-w-[520px] space-y-4 px-6 py-10">
|
<div className="mx-auto w-full max-w-[520px] space-y-4 px-6 py-10">
|
||||||
|
{/* Project name */}
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-sm">Project name</span>
|
||||||
|
<p className="mt-0.5 text-muted-foreground text-xs leading-relaxed">
|
||||||
|
This becomes the folder name on disk.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
ref={projectNameRef}
|
||||||
|
placeholder="e.g., flashvid-paper"
|
||||||
|
value={projectName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setProjectName(e.target.value);
|
||||||
|
setProjectNameError("");
|
||||||
|
}}
|
||||||
|
onBlur={() =>
|
||||||
|
setProjectNameError(getProjectNameError(projectName) ?? "")
|
||||||
|
}
|
||||||
|
className="rounded-xl border-border/60 bg-card/30 text-sm focus-visible:bg-card/50"
|
||||||
|
/>
|
||||||
|
{projectNameError && (
|
||||||
|
<p className="text-destructive text-xs">{projectNameError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Purpose */}
|
{/* Purpose */}
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-semibold text-sm">What are you writing?</label>
|
<span className="font-semibold text-sm">
|
||||||
|
What are you writing?
|
||||||
|
</span>
|
||||||
<p className="mt-0.5 text-muted-foreground text-xs leading-relaxed">
|
<p className="mt-0.5 text-muted-foreground text-xs leading-relaxed">
|
||||||
Describe your document and Claude will generate tailored content.
|
Describe your document and Claude will generate tailored
|
||||||
|
content.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|
@ -271,7 +337,7 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Collapsible sections */}
|
{/* Collapsible sections */}
|
||||||
<div className="rounded-xl border border-border/60 bg-card/30 divide-y divide-border/40 overflow-hidden">
|
<div className="divide-y divide-border/40 overflow-hidden rounded-xl border border-border/60 bg-card/30">
|
||||||
{/* Reference files */}
|
{/* Reference files */}
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -281,10 +347,10 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-muted/50">
|
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-muted/50">
|
||||||
<FileTextIcon className="size-3.5 text-muted-foreground" />
|
<FileTextIcon className="size-3.5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<span className="text-sm font-medium">Reference files</span>
|
<span className="font-medium text-sm">Reference files</span>
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary/15 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-primary">
|
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary/15 px-1.5 py-0.5 font-semibold text-[10px] text-primary leading-none">
|
||||||
{attachments.length}
|
{attachments.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -294,16 +360,18 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{refFilesOpen && (
|
{refFilesOpen && (
|
||||||
<div className="px-4 pb-4 space-y-3">
|
<div className="space-y-3 px-4 pb-4">
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{attachments.map((path) => (
|
{attachments.map((path) => (
|
||||||
<div
|
<div
|
||||||
key={path}
|
key={path}
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-border/50 bg-muted/40 pl-2.5 pr-1.5 py-1 text-xs transition-colors hover:bg-muted/60"
|
className="flex items-center gap-1.5 rounded-lg border border-border/50 bg-muted/40 py-1 pr-1.5 pl-2.5 text-xs transition-colors hover:bg-muted/60"
|
||||||
>
|
>
|
||||||
<PaperclipIcon className="size-3 shrink-0 text-muted-foreground/70" />
|
<PaperclipIcon className="size-3 shrink-0 text-muted-foreground/70" />
|
||||||
<span className="max-w-[140px] truncate text-foreground/80">{path.split("/").pop()}</span>
|
<span className="max-w-[140px] truncate text-foreground/80">
|
||||||
|
{path.split(/[/\\]/).pop()}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemoveAttachment(path)}
|
onClick={() => handleRemoveAttachment(path)}
|
||||||
className="flex size-4 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 transition-colors hover:bg-destructive/10 hover:text-destructive"
|
className="flex size-4 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
|
@ -317,23 +385,27 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col items-center gap-2 rounded-lg border border-dashed p-4 transition-all ${
|
className={`flex flex-col items-center gap-2 rounded-lg border border-dashed p-4 transition-all ${
|
||||||
isDragOver
|
isDragOver
|
||||||
? "border-primary bg-primary/5 scale-[1.01]"
|
? "scale-[1.01] border-primary bg-primary/5"
|
||||||
: "border-border/60 hover:border-border hover:bg-muted/20"
|
: "border-border/60 hover:border-border hover:bg-muted/20"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isDragOver ? (
|
{isDragOver ? (
|
||||||
<>
|
<>
|
||||||
<UploadIcon className="size-5 text-primary" />
|
<UploadIcon className="size-5 text-primary" />
|
||||||
<span className="text-xs font-medium text-primary">Drop to add</span>
|
<span className="font-medium text-primary text-xs">
|
||||||
|
Drop to add
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<UploadIcon className="size-5 text-muted-foreground/40" />
|
<UploadIcon className="size-5 text-muted-foreground/40" />
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<span className="text-xs text-muted-foreground/70">Drag & drop or </span>
|
<span className="text-muted-foreground/70 text-xs">
|
||||||
|
Drag & drop or{" "}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddAttachments}
|
onClick={handleAddAttachments}
|
||||||
className="text-xs font-medium text-foreground/70 underline underline-offset-2 decoration-border hover:text-foreground hover:decoration-foreground/50 transition-colors"
|
className="font-medium text-foreground/70 text-xs underline decoration-border underline-offset-2 transition-colors hover:text-foreground hover:decoration-foreground/50"
|
||||||
>
|
>
|
||||||
browse files
|
browse files
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -357,12 +429,13 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-muted/50">
|
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-muted/50">
|
||||||
<MapPinIcon className="size-3.5 text-muted-foreground" />
|
<MapPinIcon className="size-3.5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<span className="text-sm font-medium">Project location</span>
|
<span className="font-medium text-sm">Project location</span>
|
||||||
</div>
|
</div>
|
||||||
{!locationOpen && projectFolder && projectName.trim() && (
|
{!locationOpen && projectFolder && projectName.trim() && (
|
||||||
<span className="min-w-0 max-w-[180px] truncate rounded-md bg-muted/40 px-2 py-0.5 text-[11px] font-mono text-muted-foreground/60">
|
<span className="min-w-0 max-w-[180px] truncate rounded-md bg-muted/40 px-2 py-0.5 font-mono text-[11px] text-muted-foreground/60">
|
||||||
.../{projectFolder.split("/").pop()}/{projectName.trim()}
|
.../{projectFolder.split(/[/\\]/).pop()}/
|
||||||
|
{normalizeProjectName(projectName)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
|
|
@ -370,14 +443,13 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{locationOpen && (
|
{locationOpen && (
|
||||||
<div className="px-4 pb-4 space-y-2.5">
|
<div className="space-y-2.5 px-4 pb-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<p className="min-w-0 flex-1 truncate rounded-md bg-muted/30 px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground/60">
|
||||||
placeholder="Project name"
|
{projectFolder
|
||||||
value={projectName}
|
? `${projectFolder}/${normalizeProjectName(projectName) || "..."}`
|
||||||
onChange={(e) => setProjectName(e.target.value)}
|
: "Choose a location"}
|
||||||
className="flex-1 rounded-lg border-border/60 bg-background/50"
|
</p>
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -388,11 +460,6 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
{projectFolder ? "Change" : "Choose"}
|
{projectFolder ? "Change" : "Choose"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{projectFolder && (
|
|
||||||
<p className="truncate rounded-md bg-muted/30 px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground/60">
|
|
||||||
{projectFolder}/{projectName.trim() || "..."}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,33 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
|
|
||||||
const PHASE_MAP: Record<string, number> = {
|
const PHASE_MAP: Record<string, number> = {
|
||||||
|
"Preparing installer": 0,
|
||||||
"Checking directory permissions...": 5,
|
"Checking directory permissions...": 5,
|
||||||
"Directory permissions OK": 10,
|
"Directory permissions OK": 10,
|
||||||
"Git available": 15,
|
"Git available": 15,
|
||||||
"cloning repository": 20,
|
"cloning repository": 20,
|
||||||
|
"Downloading skills": 20,
|
||||||
"downloading tarball": 20,
|
"downloading tarball": 20,
|
||||||
"Download complete": 60,
|
"Download complete": 60,
|
||||||
"Copying skills": 70,
|
"Copying skills": 70,
|
||||||
"Copied": 90,
|
Copied: 90,
|
||||||
"Cleanup complete": 95,
|
"Cleanup complete": 95,
|
||||||
};
|
};
|
||||||
|
|
||||||
function pctFromLog(log: string): number | null {
|
function pctFromLog(log: string): number | null {
|
||||||
|
const downloadMatch = log.match(/^Download progress\s+(\d+)%/i);
|
||||||
|
if (downloadMatch?.[1]) {
|
||||||
|
const downloadPct = Math.max(0, Math.min(100, Number(downloadMatch[1])));
|
||||||
|
return Math.round(20 + downloadPct * 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadedMatch = log.match(/^Downloaded\s+(\d+)\s+MiB/i);
|
||||||
|
if (downloadedMatch?.[1]) {
|
||||||
|
const mib = Math.max(0, Number(downloadedMatch[1]));
|
||||||
|
return Math.min(55, 20 + mib);
|
||||||
|
}
|
||||||
|
|
||||||
for (const [key, pct] of Object.entries(PHASE_MAP)) {
|
for (const [key, pct] of Object.entries(PHASE_MAP)) {
|
||||||
if (log.toLowerCase().includes(key.toLowerCase())) return pct;
|
if (log.toLowerCase().includes(key.toLowerCase())) return pct;
|
||||||
}
|
}
|
||||||
|
|
@ -26,37 +38,21 @@ interface InstallProgressProps {
|
||||||
isInstalling: boolean;
|
isInstalling: boolean;
|
||||||
isComplete: boolean;
|
isComplete: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
logs: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InstallProgress({
|
export function InstallProgress({
|
||||||
isInstalling,
|
|
||||||
isComplete,
|
isComplete,
|
||||||
error,
|
error,
|
||||||
|
logs,
|
||||||
}: InstallProgressProps) {
|
}: InstallProgressProps) {
|
||||||
const [logs, setLogs] = useState<string[]>([]);
|
const pct = useMemo(() => {
|
||||||
const [pct, setPct] = useState(0);
|
if (isComplete) return 100;
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
return logs.reduce((current, line) => {
|
||||||
|
const next = pctFromLog(line);
|
||||||
useEffect(() => {
|
return next === null ? current : Math.max(current, next);
|
||||||
if (!isInstalling) return;
|
}, 0);
|
||||||
const unlisten = listen<string>("skills-install-log", (event) => {
|
}, [isComplete, logs]);
|
||||||
setLogs((prev) => [...prev, event.payload]);
|
|
||||||
const p = pctFromLog(event.payload);
|
|
||||||
if (p !== null) setPct(p);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
unlisten.then((fn) => fn());
|
|
||||||
};
|
|
||||||
}, [isInstalling]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isComplete) setPct(100);
|
|
||||||
}, [isComplete]);
|
|
||||||
|
|
||||||
// Auto-scroll to bottom
|
|
||||||
useEffect(() => {
|
|
||||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
|
||||||
}, [logs]);
|
|
||||||
|
|
||||||
const label = isComplete
|
const label = isComplete
|
||||||
? "Done"
|
? "Done"
|
||||||
|
|
@ -70,22 +66,13 @@ export function InstallProgress({
|
||||||
<div className="space-y-2 py-1">
|
<div className="space-y-2 py-1">
|
||||||
<Progress value={pct} />
|
<Progress value={pct} />
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-muted-foreground text-xs truncate max-w-[80%]">
|
<p className="max-w-[80%] truncate text-muted-foreground text-xs">
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-mono text-muted-foreground text-xs tabular-nums">
|
<p className="font-mono text-muted-foreground text-xs tabular-nums">
|
||||||
{pct}%
|
{pct}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{logs.length > 0 && (
|
|
||||||
<ScrollArea className="h-28 rounded-md border border-border/60 bg-muted/30">
|
|
||||||
<div ref={scrollRef} className="p-2 font-mono text-[11px] leading-relaxed text-muted-foreground">
|
|
||||||
{logs.map((line, i) => (
|
|
||||||
<div key={i}>{line}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
FlaskConicalIcon,
|
FlaskConicalIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
|
|
@ -10,6 +13,8 @@ import {
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
|
FolderPlusIcon,
|
||||||
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -45,6 +50,14 @@ interface SkillsStatus {
|
||||||
location: string;
|
location: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SkillInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
domain: string;
|
||||||
|
description: string;
|
||||||
|
folder: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ScientificSkillsOnboardingProps {
|
interface ScientificSkillsOnboardingProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -56,20 +69,60 @@ export function ScientificSkillsOnboarding({
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [isInstalling, setIsInstalling] = useState(false);
|
const [isInstalling, setIsInstalling] = useState(false);
|
||||||
const [isComplete, setIsComplete] = useState(false);
|
const [isComplete, setIsComplete] = useState(false);
|
||||||
const [installResult, setInstallResult] = useState<InstallResult | null>(null);
|
const [installLogs, setInstallLogs] = useState<string[]>([]);
|
||||||
|
const [installResult, setInstallResult] = useState<InstallResult | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [status, setStatus] = useState<SkillsStatus | null>(null);
|
const [status, setStatus] = useState<SkillsStatus | null>(null);
|
||||||
|
const [installedSkills, setInstalledSkills] = useState<SkillInfo[]>([]);
|
||||||
const [isUninstalling, setIsUninstalling] = useState(false);
|
const [isUninstalling, setIsUninstalling] = useState(false);
|
||||||
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
const [confirmUninstallAllOpen, setConfirmUninstallAllOpen] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<SkillEntryData | null>(null);
|
||||||
|
const [deletingSkillFolder, setDeletingSkillFolder] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
const installBackendLogSeenRef = useRef(false);
|
||||||
|
|
||||||
// Check global install status
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unlisten = listen<string>("skills-install-log", (event) => {
|
||||||
|
installBackendLogSeenRef.current = true;
|
||||||
|
setInstallLogs((previous) => {
|
||||||
|
const last = previous[previous.length - 1];
|
||||||
|
if (last === event.payload) return previous;
|
||||||
|
return [...previous, event.payload];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlisten.then((fn) => fn());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Skills are global Claude Code assets under ~/.claude/skills.
|
||||||
const checkStatus = useCallback(async () => {
|
const checkStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const gs = await invoke<SkillsStatus>("check_skills_installed", {
|
const [gs, skills] = await Promise.all([
|
||||||
projectPath: null,
|
invoke<SkillsStatus>("check_skills_installed", {
|
||||||
});
|
projectPath: null,
|
||||||
|
}),
|
||||||
|
invoke<SkillInfo[]>("list_installed_skills", {
|
||||||
|
projectPath: null,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
setStatus(gs);
|
setStatus(gs);
|
||||||
|
setInstalledSkills(skills);
|
||||||
} catch {
|
} catch {
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
|
setInstalledSkills([]);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -86,22 +139,88 @@ export function ScientificSkillsOnboarding({
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const totalSkills = categories.reduce((sum, c) => sum + c.skill_count, 0);
|
const knownSkillFolders = new Set(
|
||||||
const selected = categories.find((c) => c.id === selectedId) ?? null;
|
categories.flatMap((category) =>
|
||||||
|
category.skills.map((skill) => skill.folder),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const importedSkills = installedSkills.filter(
|
||||||
|
(skill) => !knownSkillFolders.has(skill.folder),
|
||||||
|
);
|
||||||
|
const installedSkillFolders = new Set(
|
||||||
|
installedSkills.map((skill) => skill.folder),
|
||||||
|
);
|
||||||
|
const displayCategories: SkillCategoryData[] =
|
||||||
|
importedSkills.length > 0
|
||||||
|
? [
|
||||||
|
...categories,
|
||||||
|
{
|
||||||
|
id: "imported",
|
||||||
|
name: "Imported Skills",
|
||||||
|
icon: "settings",
|
||||||
|
skill_count: importedSkills.length,
|
||||||
|
skills: importedSkills.map((skill) => ({
|
||||||
|
name: skill.name,
|
||||||
|
folder: skill.folder,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: categories;
|
||||||
|
|
||||||
|
const totalSkills = displayCategories.reduce(
|
||||||
|
(sum, c) => sum + c.skill_count,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const selected =
|
||||||
|
displayCategories.find((c) => c.id === selectedId) ??
|
||||||
|
displayCategories[0] ??
|
||||||
|
null;
|
||||||
const isInstalled = status?.installed ?? false;
|
const isInstalled = status?.installed ?? false;
|
||||||
|
|
||||||
const handleInstall = useCallback(async () => {
|
const handleInstall = useCallback(async () => {
|
||||||
|
installBackendLogSeenRef.current = false;
|
||||||
|
let noBackendLogTimer: number | undefined;
|
||||||
|
setInstallLogs(["Preparing installer..."]);
|
||||||
setIsInstalling(true);
|
setIsInstalling(true);
|
||||||
|
setIsComplete(false);
|
||||||
|
setInstallResult(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await invoke<InstallResult>("install_scientific_skills_global");
|
noBackendLogTimer = window.setTimeout(() => {
|
||||||
|
if (installBackendLogSeenRef.current || !mountedRef.current) return;
|
||||||
|
setInstallLogs((previous) => {
|
||||||
|
const hasBackendLog = previous.some(
|
||||||
|
(line) => !line.startsWith("Preparing installer"),
|
||||||
|
);
|
||||||
|
if (hasBackendLog) return previous;
|
||||||
|
return [...previous, "Waiting for the installer command to start..."];
|
||||||
|
});
|
||||||
|
}, 2500);
|
||||||
|
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 150));
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
|
||||||
|
const result = await invoke<InstallResult>(
|
||||||
|
"install_scientific_skills_global",
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
if (noBackendLogTimer !== undefined) {
|
||||||
|
window.clearTimeout(noBackendLogTimer);
|
||||||
|
}
|
||||||
|
if (!mountedRef.current) return;
|
||||||
setInstallResult(result);
|
setInstallResult(result);
|
||||||
setIsComplete(true);
|
setIsComplete(true);
|
||||||
localStorage.setItem(STORAGE_KEY, "true");
|
localStorage.setItem(STORAGE_KEY, "true");
|
||||||
await checkStatus();
|
await checkStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
if (noBackendLogTimer !== undefined) {
|
||||||
|
window.clearTimeout(noBackendLogTimer);
|
||||||
|
}
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
const message = String(e);
|
||||||
|
setInstallLogs((previous) => [...previous, message]);
|
||||||
|
setError(message);
|
||||||
setIsInstalling(false);
|
setIsInstalling(false);
|
||||||
}
|
}
|
||||||
}, [checkStatus]);
|
}, [checkStatus]);
|
||||||
|
|
@ -109,36 +228,125 @@ export function ScientificSkillsOnboarding({
|
||||||
const handleUninstall = useCallback(async () => {
|
const handleUninstall = useCallback(async () => {
|
||||||
setIsUninstalling(true);
|
setIsUninstalling(true);
|
||||||
try {
|
try {
|
||||||
await invoke("uninstall_scientific_skills", { projectPath: null });
|
await invoke("uninstall_scientific_skills", {
|
||||||
|
projectPath: null,
|
||||||
|
});
|
||||||
await checkStatus();
|
await checkStatus();
|
||||||
const gsAfter = await invoke<SkillsStatus>("check_skills_installed", { projectPath: null });
|
const gsAfter = await invoke<SkillsStatus>("check_skills_installed", {
|
||||||
|
projectPath: null,
|
||||||
|
});
|
||||||
if (!gsAfter.installed) {
|
if (!gsAfter.installed) {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
}
|
}
|
||||||
|
toast.success("All skills uninstalled");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to uninstall:", e);
|
console.error("Failed to uninstall:", e);
|
||||||
|
toast.error("Failed to uninstall skills", {
|
||||||
|
description: String(e),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsUninstalling(false);
|
setIsUninstalling(false);
|
||||||
}
|
}
|
||||||
}, [checkStatus]);
|
}, [checkStatus]);
|
||||||
|
|
||||||
|
const handleImportSkill = useCallback(async () => {
|
||||||
|
setIsImporting(true);
|
||||||
|
try {
|
||||||
|
const selectedFolder = await open({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
title: "Import Claude Skill Folder",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof selectedFolder !== "string") return;
|
||||||
|
|
||||||
|
const skills = await invoke<SkillInfo[]>("import_skill_from_folder", {
|
||||||
|
sourcePath: selectedFolder,
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.setItem(STORAGE_KEY, "true");
|
||||||
|
await checkStatus();
|
||||||
|
setSelectedId("imported");
|
||||||
|
toast.success("Skill imported", {
|
||||||
|
description:
|
||||||
|
skills.length === 1
|
||||||
|
? skills[0]?.name
|
||||||
|
: `${skills.length} skills imported`,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Failed to import skill", {
|
||||||
|
description: String(e),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsImporting(false);
|
||||||
|
}
|
||||||
|
}, [checkStatus]);
|
||||||
|
|
||||||
|
const handleConfirmDeleteSkill = useCallback(async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setDeletingSkillFolder(deleteTarget.folder);
|
||||||
|
try {
|
||||||
|
await invoke("delete_installed_skill", {
|
||||||
|
skillFolder: deleteTarget.folder,
|
||||||
|
});
|
||||||
|
toast.success("Skill deleted", {
|
||||||
|
description: deleteTarget.name,
|
||||||
|
});
|
||||||
|
setDeleteTarget(null);
|
||||||
|
await checkStatus();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Failed to delete skill", {
|
||||||
|
description: String(e),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeletingSkillFolder(null);
|
||||||
|
}
|
||||||
|
}, [checkStatus, deleteTarget]);
|
||||||
|
|
||||||
// ─── Installing / Complete state ───
|
// ─── Installing / Complete state ───
|
||||||
if (isInstalling || isComplete) {
|
if (isInstalling || isComplete || error) {
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(open) => { if (!open && (isComplete || error)) onClose(); }}>
|
<Dialog
|
||||||
<DialogContent className="sm:max-w-md">
|
open
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
className="gap-3 px-6 pt-6 pb-4 sm:max-w-md"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close"
|
||||||
|
className="absolute top-4 right-4 z-10 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
</button>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2 text-sm">
|
<DialogTitle className="flex items-center gap-2 text-sm">
|
||||||
{isComplete ? (
|
{isComplete ? (
|
||||||
<CheckCircle2Icon className="size-5 text-foreground" />
|
<CheckCircle2Icon className="size-5 text-foreground" />
|
||||||
|
) : error ? (
|
||||||
|
<AlertCircleIcon className="size-5 text-destructive" />
|
||||||
) : (
|
) : (
|
||||||
<FlaskConicalIcon className="size-5 text-muted-foreground" />
|
<FlaskConicalIcon className="size-5 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
{isComplete ? "Installation Complete" : "Installing Skills"}
|
{isComplete
|
||||||
|
? "Installation Complete"
|
||||||
|
: error
|
||||||
|
? "Installation Failed"
|
||||||
|
: "Installing Skills"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
{isComplete && (
|
{isComplete && (
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{installResult?.skills_installed ?? 0} scientific skills are now available.
|
{installResult?.skills_installed ?? 0} scientific skills are now
|
||||||
|
available.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
)}
|
)}
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
@ -147,12 +355,15 @@ export function ScientificSkillsOnboarding({
|
||||||
isInstalling={isInstalling}
|
isInstalling={isInstalling}
|
||||||
isComplete={isComplete}
|
isComplete={isComplete}
|
||||||
error={error}
|
error={error}
|
||||||
|
logs={installLogs}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3">
|
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3">
|
||||||
<AlertCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />
|
<AlertCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />
|
||||||
<p className="text-xs leading-relaxed text-muted-foreground">{error}</p>
|
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -161,7 +372,7 @@ export function ScientificSkillsOnboarding({
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => { setError(null); setIsInstalling(false); }}
|
onClick={handleInstall}
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
>
|
>
|
||||||
<RefreshCwIcon className="size-3.5" />
|
<RefreshCwIcon className="size-3.5" />
|
||||||
|
|
@ -181,132 +392,254 @@ export function ScientificSkillsOnboarding({
|
||||||
|
|
||||||
// ─── Browse state — two-column layout ───
|
// ─── Browse state — two-column layout ───
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
|
<>
|
||||||
<DialogContent
|
<Dialog
|
||||||
showCloseButton={false}
|
open
|
||||||
className="flex max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none w-[min(56rem,calc(100vw-4rem))] h-[min(36rem,calc(100vh-6rem))]"
|
onOpenChange={(open) => {
|
||||||
|
if (!open) onClose();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
<DialogContent
|
||||||
<DialogHeader className="shrink-0 border-b border-border px-6 py-3">
|
showCloseButton={false}
|
||||||
<div className="flex items-center gap-4">
|
className="flex h-[min(36rem,calc(100vh-6rem))] w-[min(56rem,calc(100vw-4rem))] max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none"
|
||||||
<div className="min-w-0 flex-1">
|
>
|
||||||
<DialogTitle className="text-sm">Scientific Skills</DialogTitle>
|
{/* Header */}
|
||||||
<DialogDescription className="mt-0.5 text-xs">
|
<DialogHeader className="shrink-0 border-border border-b px-6 py-3">
|
||||||
{totalSkills} AI skills across {categories.length} domains — powered by{" "}
|
<div className="flex items-center gap-4">
|
||||||
<a
|
<div className="min-w-0 flex-1">
|
||||||
href="https://github.com/K-Dense-AI/claude-scientific-skills"
|
<DialogTitle className="text-sm">Skills</DialogTitle>
|
||||||
target="_blank"
|
<DialogDescription className="mt-0.5 text-xs">
|
||||||
rel="noopener noreferrer"
|
{totalSkills} skills across {displayCategories.length} groups
|
||||||
className="inline-flex items-center gap-0.5 underline decoration-border underline-offset-2 hover:text-foreground"
|
- install curated scientific skills or import a local Claude
|
||||||
|
skill. Curated set powered by{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/K-Dense-AI/scientific-agent-skills"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-0.5 underline decoration-border underline-offset-2 hover:text-foreground"
|
||||||
|
>
|
||||||
|
K-Dense
|
||||||
|
<ExternalLinkIcon className="size-2.5" />
|
||||||
|
</a>
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{isInstalled ? (
|
||||||
|
<>
|
||||||
|
<Badge variant="secondary" className="gap-1 text-xs">
|
||||||
|
<CheckCircle2Icon className="size-3" />
|
||||||
|
{status?.skill_count} installed
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleInstall}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className="size-3.5" />
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setConfirmUninstallAllOpen(true)}
|
||||||
|
disabled={isUninstalling}
|
||||||
|
className="gap-1.5 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
{isUninstalling ? (
|
||||||
|
<Loader2Icon className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
)}
|
||||||
|
Uninstall
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" onClick={handleInstall} className="gap-1.5">
|
||||||
|
<DownloadIcon className="size-3.5" />
|
||||||
|
Install All
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleImportSkill}
|
||||||
|
disabled={isImporting || isInstalling || isUninstalling}
|
||||||
|
className="gap-1.5 border-border/70 bg-muted/30 text-foreground shadow-none hover:bg-muted/60 hover:text-foreground"
|
||||||
>
|
>
|
||||||
K-Dense
|
{isImporting ? (
|
||||||
<ExternalLinkIcon className="size-2.5" />
|
<Loader2Icon className="size-3.5 animate-spin text-muted-foreground" />
|
||||||
</a>
|
) : (
|
||||||
</DialogDescription>
|
<FolderPlusIcon className="size-3.5 text-muted-foreground" />
|
||||||
</div>
|
)}
|
||||||
<div className="flex shrink-0 items-center gap-3">
|
Import Skill
|
||||||
{isInstalled ? (
|
|
||||||
<>
|
|
||||||
<Badge variant="secondary" className="gap-1 text-xs">
|
|
||||||
<CheckCircle2Icon className="size-3" />
|
|
||||||
{status?.skill_count} installed
|
|
||||||
</Badge>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleInstall}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
<RefreshCwIcon className="size-3.5" />
|
|
||||||
Update
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleUninstall}
|
|
||||||
disabled={isUninstalling}
|
|
||||||
className="gap-1.5 text-destructive hover:text-destructive"
|
|
||||||
>
|
|
||||||
{isUninstalling ? (
|
|
||||||
<Loader2Icon className="size-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2Icon className="size-3.5" />
|
|
||||||
)}
|
|
||||||
Uninstall
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button size="sm" onClick={handleInstall} className="gap-1.5">
|
|
||||||
<DownloadIcon className="size-3.5" />
|
|
||||||
Install All
|
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Body — sidebar + detail */}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* Category sidebar */}
|
||||||
|
<nav className="w-64 max-w-64 shrink-0 overflow-hidden border-border border-r">
|
||||||
|
<ScrollArea className="h-full w-full overflow-hidden [&_[data-slot=scroll-area-scrollbar]]:hidden">
|
||||||
|
<div className="box-border flex w-full min-w-0 flex-col gap-0.5 overflow-x-hidden p-2">
|
||||||
|
{displayCategories.map((cat) => {
|
||||||
|
const Icon = ICON_MAP[cat.icon] || FlaskConicalIcon;
|
||||||
|
const isActive = selectedId === cat.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cat.id}
|
||||||
|
onClick={() => setSelectedId(cat.id)}
|
||||||
|
className={cn(
|
||||||
|
"box-border grid w-full min-w-0 max-w-full grid-cols-[1rem_minmax(0,1fr)] items-center gap-2.5 overflow-hidden rounded-lg px-3 py-2 text-left text-sm transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-accent font-medium text-accent-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
title={cat.name}
|
||||||
|
>
|
||||||
|
<Icon className="size-4 shrink-0" />
|
||||||
|
<span className="block min-w-0 truncate">
|
||||||
|
{cat.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Detail panel */}
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
{selected ? (
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-6">
|
||||||
|
<CategoryDetail
|
||||||
|
category={selected}
|
||||||
|
isInstalled={isInstalled}
|
||||||
|
installedSkillFolders={installedSkillFolders}
|
||||||
|
deletingSkillFolder={deletingSkillFolder}
|
||||||
|
onDeleteSkill={setDeleteTarget}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-1 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
Select a category
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{/* Body — sidebar + detail */}
|
{/* Footer */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex shrink-0 items-center justify-between border-border border-t bg-muted/20 px-6 py-2.5">
|
||||||
{/* Category sidebar */}
|
<p className="font-mono text-[11px] text-muted-foreground/60">
|
||||||
<nav className="w-64 shrink-0 overflow-hidden border-r border-border">
|
{status?.location ?? "~/.claude/skills/"}
|
||||||
<ScrollArea className="h-full">
|
</p>
|
||||||
<div className="flex flex-col gap-0.5 p-2">
|
<Button
|
||||||
{categories.map((cat) => {
|
variant="ghost"
|
||||||
const Icon = ICON_MAP[cat.icon] || FlaskConicalIcon;
|
size="sm"
|
||||||
const isActive = selectedId === cat.id;
|
onClick={onClose}
|
||||||
return (
|
className="text-muted-foreground"
|
||||||
<button
|
>
|
||||||
key={cat.id}
|
Close
|
||||||
onClick={() => setSelectedId(cat.id)}
|
</Button>
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2.5 overflow-hidden rounded-lg px-3 py-2 text-left text-sm transition-colors",
|
|
||||||
isActive
|
|
||||||
? "bg-accent font-medium text-accent-foreground"
|
|
||||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="size-4 shrink-0" />
|
|
||||||
<span className="min-w-0 flex-1 truncate">{cat.name}</span>
|
|
||||||
<span className="text-xs tabular-nums text-muted-foreground">
|
|
||||||
{cat.skill_count}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Detail panel */}
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
|
||||||
{selected ? (
|
|
||||||
<ScrollArea className="flex-1">
|
|
||||||
<div className="p-6">
|
|
||||||
<CategoryDetail
|
|
||||||
category={selected}
|
|
||||||
isInstalled={isInstalled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-1 items-center justify-center text-muted-foreground text-sm">
|
|
||||||
Select a category
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Footer */}
|
<Dialog
|
||||||
<div className="flex shrink-0 items-center justify-between border-t border-border bg-muted/20 px-6 py-2.5">
|
open={deleteTarget !== null}
|
||||||
<p className="font-mono text-muted-foreground/60 text-[11px]">
|
onOpenChange={(open) => {
|
||||||
{isInstalled ? status?.location : "~/.claude/skills/"}
|
if (!open && !deletingSkillFolder) setDeleteTarget(null);
|
||||||
</p>
|
}}
|
||||||
<Button variant="ghost" size="sm" onClick={onClose} className="text-muted-foreground">
|
>
|
||||||
Close
|
<DialogContent className="sm:max-w-sm">
|
||||||
</Button>
|
<DialogHeader>
|
||||||
</div>
|
<DialogTitle>Delete Skill</DialogTitle>
|
||||||
</DialogContent>
|
<DialogDescription>
|
||||||
</Dialog>
|
Delete {deleteTarget?.name ?? "this skill"} from ~/.claude/skills.
|
||||||
|
This cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2 font-mono text-muted-foreground text-xs">
|
||||||
|
{deleteTarget?.folder}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={deletingSkillFolder !== null}
|
||||||
|
onClick={() => setDeleteTarget(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={deletingSkillFolder !== null}
|
||||||
|
onClick={handleConfirmDeleteSkill}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{deletingSkillFolder ? (
|
||||||
|
<Loader2Icon className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
)}
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={confirmUninstallAllOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !isUninstalling) setConfirmUninstallAllOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Uninstall All Skills</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will delete every skill under ~/.claude/skills, including
|
||||||
|
imported local skills. This cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive text-xs">
|
||||||
|
{status?.skill_count ?? 0} installed skills will be removed.
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={isUninstalling}
|
||||||
|
onClick={() => setConfirmUninstallAllOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={isUninstalling}
|
||||||
|
onClick={async () => {
|
||||||
|
await handleUninstall();
|
||||||
|
setConfirmUninstallAllOpen(false);
|
||||||
|
}}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{isUninstalling ? (
|
||||||
|
<Loader2Icon className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
)}
|
||||||
|
Uninstall All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -315,12 +648,20 @@ export function ScientificSkillsOnboarding({
|
||||||
function CategoryDetail({
|
function CategoryDetail({
|
||||||
category,
|
category,
|
||||||
isInstalled,
|
isInstalled,
|
||||||
|
installedSkillFolders,
|
||||||
|
deletingSkillFolder,
|
||||||
|
onDeleteSkill,
|
||||||
}: {
|
}: {
|
||||||
category: SkillCategoryData;
|
category: SkillCategoryData;
|
||||||
isInstalled: boolean;
|
isInstalled: boolean;
|
||||||
|
installedSkillFolders: Set<string>;
|
||||||
|
deletingSkillFolder: string | null;
|
||||||
|
onDeleteSkill: (skill: SkillEntryData) => void;
|
||||||
}) {
|
}) {
|
||||||
const Icon = ICON_MAP[category.icon] || FlaskConicalIcon;
|
const Icon = ICON_MAP[category.icon] || FlaskConicalIcon;
|
||||||
const [selectedSkill, setSelectedSkill] = useState<SkillEntryData | null>(null);
|
const [selectedSkill, setSelectedSkill] = useState<SkillEntryData | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [skillContent, setSkillContent] = useState<string | null>(null);
|
const [skillContent, setSkillContent] = useState<string | null>(null);
|
||||||
const [loadingContent, setLoadingContent] = useState(false);
|
const [loadingContent, setLoadingContent] = useState(false);
|
||||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
|
@ -364,8 +705,12 @@ function CategoryDetail({
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setSelectedSkill(null); setSkillContent(null); setFetchError(null); }}
|
onClick={() => {
|
||||||
className="mb-3 flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
setSelectedSkill(null);
|
||||||
|
setSkillContent(null);
|
||||||
|
setFetchError(null);
|
||||||
|
}}
|
||||||
|
className="mb-3 flex items-center gap-1 text-muted-foreground text-xs transition-colors hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon className="size-3.5" />
|
<ChevronLeftIcon className="size-3.5" />
|
||||||
{category.name}
|
{category.name}
|
||||||
|
|
@ -381,22 +726,40 @@ function CategoryDetail({
|
||||||
{selectedSkill.folder}
|
{selectedSkill.folder}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{installedSkillFolders.has(selectedSkill.folder) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8 shrink-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
disabled={deletingSkillFolder === selectedSkill.folder}
|
||||||
|
onClick={() => onDeleteSkill(selectedSkill)}
|
||||||
|
title="Delete skill"
|
||||||
|
>
|
||||||
|
{deletingSkillFolder === selectedSkill.folder ? (
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2Icon className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
|
|
||||||
{loadingContent ? (
|
{loadingContent ? (
|
||||||
<div className="flex items-center gap-2 py-4 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 py-4 text-muted-foreground text-xs">
|
||||||
<Loader2Icon className="size-3.5 animate-spin" />
|
<Loader2Icon className="size-3.5 animate-spin" />
|
||||||
Loading skill content…
|
Loading skill content…
|
||||||
</div>
|
</div>
|
||||||
) : fetchError ? (
|
) : fetchError ? (
|
||||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3">
|
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3">
|
||||||
<AlertCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />
|
<AlertCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />
|
||||||
<p className="text-xs leading-relaxed text-muted-foreground">{fetchError}</p>
|
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||||
|
{fetchError}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : skillContent ? (
|
) : skillContent ? (
|
||||||
<div className="whitespace-pre-wrap rounded-lg border border-border/60 bg-muted/30 p-4 font-mono text-xs leading-relaxed text-foreground/80">
|
<div className="whitespace-pre-wrap rounded-lg border border-border/60 bg-muted/30 p-4 font-mono text-foreground/80 text-xs leading-relaxed">
|
||||||
{skillContent}
|
{skillContent}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -435,16 +798,44 @@ function CategoryDetail({
|
||||||
Skills
|
Skills
|
||||||
</h4>
|
</h4>
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
{category.skills.map((skill) => (
|
{category.skills.map((skill) => {
|
||||||
<button
|
const canDelete = installedSkillFolders.has(skill.folder);
|
||||||
key={skill.folder}
|
const isDeleting = deletingSkillFolder === skill.folder;
|
||||||
onClick={() => handleSkillClick(skill)}
|
return (
|
||||||
className="flex items-center gap-2 rounded-lg border border-border/60 bg-card/30 px-3 py-2 text-left text-sm transition-colors hover:border-border hover:bg-accent/30"
|
<div
|
||||||
>
|
key={skill.folder}
|
||||||
<span className="size-1.5 shrink-0 rounded-full bg-foreground/40" />
|
className="group flex min-w-0 items-center rounded-lg border border-border/60 bg-card/30 transition-colors hover:border-border hover:bg-accent/30"
|
||||||
{skill.name}
|
>
|
||||||
</button>
|
<button
|
||||||
))}
|
type="button"
|
||||||
|
onClick={() => handleSkillClick(skill)}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm"
|
||||||
|
>
|
||||||
|
<span className="size-1.5 shrink-0 rounded-full bg-foreground/40" />
|
||||||
|
<span className="min-w-0 truncate">{skill.name}</span>
|
||||||
|
</button>
|
||||||
|
{canDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Delete ${skill.name}`}
|
||||||
|
title="Delete skill"
|
||||||
|
disabled={isDeleting}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDeleteSkill(skill);
|
||||||
|
}}
|
||||||
|
className="mr-1 flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-70 transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<Loader2Icon className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,6 @@ import {
|
||||||
type TemplateCategory,
|
type TemplateCategory,
|
||||||
type TemplateSubcategory,
|
type TemplateSubcategory,
|
||||||
CATEGORY_LABELS,
|
CATEGORY_LABELS,
|
||||||
SUBCATEGORY_LABELS,
|
|
||||||
CATEGORY_SUBCATEGORIES,
|
|
||||||
getCategories,
|
getCategories,
|
||||||
getAllTemplates,
|
getAllTemplates,
|
||||||
getTemplatesByCategory,
|
getTemplatesByCategory,
|
||||||
|
|
@ -32,7 +30,7 @@ const CATEGORY_ICONS: Record<TemplateCategory, React.ReactNode> = {
|
||||||
starter: <SparklesIcon className="size-4" />,
|
starter: <SparklesIcon className="size-4" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SUBCATEGORY_ICONS: Record<TemplateSubcategory, React.ReactNode> = {
|
const _SUBCATEGORY_ICONS: Record<TemplateSubcategory, React.ReactNode> = {
|
||||||
papers: <FileTextIcon className="size-3.5" />,
|
papers: <FileTextIcon className="size-3.5" />,
|
||||||
theses: <GraduationCapIcon className="size-3.5" />,
|
theses: <GraduationCapIcon className="size-3.5" />,
|
||||||
presentations: <MonitorIcon className="size-3.5" />,
|
presentations: <MonitorIcon className="size-3.5" />,
|
||||||
|
|
@ -63,7 +61,9 @@ export function CategorySidebar() {
|
||||||
>
|
>
|
||||||
<SparklesIcon className="size-4" />
|
<SparklesIcon className="size-4" />
|
||||||
<span className="flex-1">All Templates</span>
|
<span className="flex-1">All Templates</span>
|
||||||
<span className="text-xs tabular-nums text-muted-foreground">{allCount}</span>
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
|
{allCount}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="my-1.5 h-px bg-border" />
|
<div className="my-1.5 h-px bg-border" />
|
||||||
|
|
@ -84,7 +84,9 @@ export function CategorySidebar() {
|
||||||
>
|
>
|
||||||
{CATEGORY_ICONS[cat]}
|
{CATEGORY_ICONS[cat]}
|
||||||
<span className="flex-1">{CATEGORY_LABELS[cat]}</span>
|
<span className="flex-1">{CATEGORY_LABELS[cat]}</span>
|
||||||
<span className="text-xs tabular-nums text-muted-foreground">{count}</span>
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,20 @@ import {
|
||||||
export function ThumbnailPaper({ color }: { color: string }) {
|
export function ThumbnailPaper({ color }: { color: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col items-center px-4 py-3">
|
<div className="flex h-full w-full flex-col items-center px-4 py-3">
|
||||||
<div className="mb-1.5 h-1.5 w-12 rounded-full" style={{ backgroundColor: color }} />
|
<div
|
||||||
|
className="mb-1.5 h-1.5 w-12 rounded-full"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
<div className="mb-3 h-1 w-8 rounded-full bg-muted-foreground/20" />
|
<div className="mb-3 h-1 w-8 rounded-full bg-muted-foreground/20" />
|
||||||
<div className="mb-2 w-full rounded-sm bg-muted-foreground/8 p-1.5">
|
<div className="mb-2 w-full rounded-sm bg-muted-foreground/8 p-1.5">
|
||||||
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/15" />
|
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/15" />
|
||||||
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/15" />
|
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/15" />
|
||||||
<div className="h-0.5 w-3/4 rounded-full bg-muted-foreground/15" />
|
<div className="h-0.5 w-3/4 rounded-full bg-muted-foreground/15" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-1.5 h-1 w-10 self-start rounded-full" style={{ backgroundColor: color, opacity: 0.6 }} />
|
<div
|
||||||
|
className="mb-1.5 h-1 w-10 self-start rounded-full"
|
||||||
|
style={{ backgroundColor: color, opacity: 0.6 }}
|
||||||
|
/>
|
||||||
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/12" />
|
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/12" />
|
||||||
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/12" />
|
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/12" />
|
||||||
<div className="mb-1 h-0.5 w-11/12 rounded-full bg-muted-foreground/12" />
|
<div className="mb-1 h-0.5 w-11/12 rounded-full bg-muted-foreground/12" />
|
||||||
|
|
@ -35,11 +41,17 @@ export function ThumbnailSlides({ color }: { color: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1.5 px-3 py-2">
|
<div className="flex h-full w-full flex-col items-center justify-center gap-1.5 px-3 py-2">
|
||||||
<div className="flex w-full flex-1 flex-col items-center justify-center rounded-sm border border-muted-foreground/10 bg-muted-foreground/5 p-1">
|
<div className="flex w-full flex-1 flex-col items-center justify-center rounded-sm border border-muted-foreground/10 bg-muted-foreground/5 p-1">
|
||||||
<div className="mb-0.5 h-1 w-10 rounded-full" style={{ backgroundColor: color }} />
|
<div
|
||||||
|
className="mb-0.5 h-1 w-10 rounded-full"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
<div className="h-0.5 w-6 rounded-full bg-muted-foreground/20" />
|
<div className="h-0.5 w-6 rounded-full bg-muted-foreground/20" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-1 flex-col rounded-sm border border-muted-foreground/10 bg-muted-foreground/5 p-1">
|
<div className="flex w-full flex-1 flex-col rounded-sm border border-muted-foreground/10 bg-muted-foreground/5 p-1">
|
||||||
<div className="mb-0.5 h-0.5 w-6 rounded-full" style={{ backgroundColor: color, opacity: 0.6 }} />
|
<div
|
||||||
|
className="mb-0.5 h-0.5 w-6 rounded-full"
|
||||||
|
style={{ backgroundColor: color, opacity: 0.6 }}
|
||||||
|
/>
|
||||||
<div className="mb-0.5 h-0.5 w-full rounded-full bg-muted-foreground/12" />
|
<div className="mb-0.5 h-0.5 w-full rounded-full bg-muted-foreground/12" />
|
||||||
<div className="h-0.5 w-3/4 rounded-full bg-muted-foreground/12" />
|
<div className="h-0.5 w-3/4 rounded-full bg-muted-foreground/12" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -47,7 +59,7 @@ export function ThumbnailSlides({ color }: { color: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThumbnailPoster({ color }: { color: string }) {
|
export function ThumbnailPoster({ color: _color }: { color: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col px-2 py-2">
|
<div className="flex h-full w-full flex-col px-2 py-2">
|
||||||
<div className="mb-2 h-1 w-10 self-center rounded-full bg-muted-foreground/20" />
|
<div className="mb-2 h-1 w-10 self-center rounded-full bg-muted-foreground/20" />
|
||||||
|
|
@ -70,7 +82,7 @@ export function ThumbnailPoster({ color }: { color: string }) {
|
||||||
export function ThumbnailBlank(_props: { color: string }) {
|
export function ThumbnailBlank(_props: { color: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<div className="text-muted-foreground/20 text-xs font-medium">Empty</div>
|
<div className="font-medium text-muted-foreground/20 text-xs">Empty</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -86,11 +98,13 @@ export const THUMBNAIL_MAP: Record<string, React.FC<{ color: string }>> = {
|
||||||
"letter-formal": ThumbnailPaper,
|
"letter-formal": ThumbnailPaper,
|
||||||
"report-technical": ThumbnailPaper,
|
"report-technical": ThumbnailPaper,
|
||||||
"book-standard": ThumbnailPaper,
|
"book-standard": ThumbnailPaper,
|
||||||
"newsletter": ThumbnailPaper,
|
newsletter: ThumbnailPaper,
|
||||||
"blank": ThumbnailBlank,
|
blank: ThumbnailBlank,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getFallbackThumbnail(template: TemplateDefinition): React.FC<{ color: string }> {
|
export function getFallbackThumbnail(
|
||||||
|
template: TemplateDefinition,
|
||||||
|
): React.FC<{ color: string }> {
|
||||||
return THUMBNAIL_MAP[template.id] || ThumbnailPaper;
|
return THUMBNAIL_MAP[template.id] || ThumbnailPaper;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,14 +118,12 @@ export function TemplateCard({ template }: TemplateCardProps) {
|
||||||
const openPreview = useTemplateStore((s) => s.openPreview);
|
const openPreview = useTemplateStore((s) => s.openPreview);
|
||||||
const FallbackThumbnail = getFallbackThumbnail(template);
|
const FallbackThumbnail = getFallbackThumbnail(template);
|
||||||
|
|
||||||
const thumbnailUrl = useSyncExternalStore(
|
const thumbnailUrl = useSyncExternalStore(subscribeThumbnails, () =>
|
||||||
subscribeThumbnails,
|
getThumbnail(template.id),
|
||||||
() => getThumbnail(template.id),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const failed = useSyncExternalStore(
|
const failed = useSyncExternalStore(subscribeThumbnails, () =>
|
||||||
subscribeThumbnails,
|
isThumbnailFailed(template.id),
|
||||||
() => isThumbnailFailed(template.id),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -149,7 +161,7 @@ export function TemplateCard({ template }: TemplateCardProps) {
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 px-0.5">
|
<div className="mt-2 px-0.5">
|
||||||
<div className="font-medium text-sm leading-tight">{template.name}</div>
|
<div className="font-medium text-sm leading-tight">{template.name}</div>
|
||||||
<div className="mt-0.5 text-muted-foreground text-xs leading-snug line-clamp-2">
|
<div className="mt-0.5 line-clamp-2 text-muted-foreground text-xs leading-snug">
|
||||||
{template.description}
|
{template.description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ export function TemplateGallery() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* Search bar */}
|
{/* Search bar */}
|
||||||
<div className="shrink-0 border-b border-border px-4 py-3">
|
<div className="shrink-0 border-border border-b px-4 py-3">
|
||||||
<div className="relative mx-auto max-w-xl">
|
<div className="relative mx-auto max-w-xl">
|
||||||
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -61,7 +61,7 @@ export function TemplateGallery() {
|
||||||
placeholder="Search templates... ⌘K"
|
placeholder="Search templates... ⌘K"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-9 pr-8"
|
className="pr-8 pl-9"
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -77,7 +77,7 @@ export function TemplateGallery() {
|
||||||
{/* Main content: sidebar + grid */}
|
{/* Main content: sidebar + grid */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Category sidebar */}
|
{/* Category sidebar */}
|
||||||
<div className="shrink-0 border-r border-border pl-3 pt-2">
|
<div className="shrink-0 border-border border-r pt-2 pl-3">
|
||||||
<CategorySidebar />
|
<CategorySidebar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -86,8 +86,10 @@ export function TemplateGallery() {
|
||||||
{filteredTemplates.length === 0 ? (
|
{filteredTemplates.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
<SearchIcon className="mb-3 size-8 text-muted-foreground/40" />
|
<SearchIcon className="mb-3 size-8 text-muted-foreground/40" />
|
||||||
<p className="font-medium text-sm text-muted-foreground">No templates found</p>
|
<p className="font-medium text-muted-foreground text-sm">
|
||||||
<p className="mt-1 text-xs text-muted-foreground/70">
|
No templates found
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-muted-foreground/70 text-xs">
|
||||||
Try a different search term or category
|
Try a different search term or category
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -95,7 +97,9 @@ export function TemplateGallery() {
|
||||||
<GroupedGrid />
|
<GroupedGrid />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<h2 className="mb-4 font-medium text-sm text-muted-foreground">{heading}</h2>
|
<h2 className="mb-4 font-medium text-muted-foreground text-sm">
|
||||||
|
{heading}
|
||||||
|
</h2>
|
||||||
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
{filteredTemplates.map((t) => (
|
{filteredTemplates.map((t) => (
|
||||||
<TemplateCard key={t.id} template={t} />
|
<TemplateCard key={t.id} template={t} />
|
||||||
|
|
@ -118,7 +122,12 @@ function GroupedGrid() {
|
||||||
const filteredTemplates = useTemplateStore((s) => s.filteredTemplates);
|
const filteredTemplates = useTemplateStore((s) => s.filteredTemplates);
|
||||||
|
|
||||||
// Group by category preserving order
|
// Group by category preserving order
|
||||||
const categories: TemplateCategory[] = ["academic", "professional", "creative", "starter"];
|
const categories: TemplateCategory[] = [
|
||||||
|
"academic",
|
||||||
|
"professional",
|
||||||
|
"creative",
|
||||||
|
"starter",
|
||||||
|
];
|
||||||
const groups = categories
|
const groups = categories
|
||||||
.map((cat) => ({
|
.map((cat) => ({
|
||||||
category: cat,
|
category: cat,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { mkdir, writeTextFile } from "@tauri-apps/plugin-fs";
|
import { mkdir, writeTextFile } from "@tauri-apps/plugin-fs";
|
||||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||||
import { documentDir } from "@tauri-apps/api/path";
|
import { homeDir } from "@tauri-apps/api/path";
|
||||||
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
|
|
@ -32,23 +33,26 @@ import { useTemplateStore } from "@/stores/template-store";
|
||||||
import { useProjectStore } from "@/stores/project-store";
|
import { useProjectStore } from "@/stores/project-store";
|
||||||
import { useDocumentStore } from "@/stores/document-store";
|
import { useDocumentStore } from "@/stores/document-store";
|
||||||
import { useClaudeChatStore } from "@/stores/claude-chat-store";
|
import { useClaudeChatStore } from "@/stores/claude-chat-store";
|
||||||
import { getTemplateById, getTemplateSkeleton, BIB_TEMPLATE } from "@/lib/template-registry";
|
import {
|
||||||
|
getTemplateById,
|
||||||
|
getTemplateSkeleton,
|
||||||
|
BIB_TEMPLATE,
|
||||||
|
} from "@/lib/template-registry";
|
||||||
import { getTemplatePdfUrl } from "@/lib/template-preview-cache";
|
import { getTemplatePdfUrl } from "@/lib/template-preview-cache";
|
||||||
import { getMupdfClient } from "@/lib/mupdf/mupdf-client";
|
import { getMupdfClient } from "@/lib/mupdf/mupdf-client";
|
||||||
import { exists, join } from "@/lib/tauri/fs";
|
import { exists, join } from "@/lib/tauri/fs";
|
||||||
import type { PageSize } from "@/lib/mupdf/types";
|
import type { PageSize } from "@/lib/mupdf/types";
|
||||||
|
import { createLogger } from "@/lib/debug/logger";
|
||||||
|
import {
|
||||||
|
buildReferenceFilesSection,
|
||||||
|
importReferenceFiles,
|
||||||
|
} from "@/lib/project-attachments";
|
||||||
|
import { getProjectNameError, normalizeProjectName } from "@/lib/project-name";
|
||||||
|
|
||||||
|
const log = createLogger("template-preview");
|
||||||
|
|
||||||
// ─── Helpers ───
|
// ─── Helpers ───
|
||||||
|
|
||||||
function randomProjectName(): string {
|
|
||||||
const adjectives = ["swift", "bright", "calm", "bold", "keen", "warm", "pure", "vast", "deep", "fair"];
|
|
||||||
const nouns = ["paper", "draft", "thesis", "note", "study", "essay", "report", "brief", "folio", "opus"];
|
|
||||||
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
|
|
||||||
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
|
||||||
const id = Math.random().toString(36).slice(2, 6);
|
|
||||||
return `${adj}-${noun}-${id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Component ───
|
// ─── Component ───
|
||||||
|
|
||||||
type ModalStep = "preview" | "details";
|
type ModalStep = "preview" | "details";
|
||||||
|
|
@ -56,7 +60,9 @@ type ModalStep = "preview" | "details";
|
||||||
export function TemplatePreview() {
|
export function TemplatePreview() {
|
||||||
const previewTemplateId = useTemplateStore((s) => s.previewTemplateId);
|
const previewTemplateId = useTemplateStore((s) => s.previewTemplateId);
|
||||||
const closePreview = useTemplateStore((s) => s.closePreview);
|
const closePreview = useTemplateStore((s) => s.closePreview);
|
||||||
const template = previewTemplateId ? getTemplateById(previewTemplateId) : null;
|
const template = previewTemplateId
|
||||||
|
? getTemplateById(previewTemplateId)
|
||||||
|
: null;
|
||||||
|
|
||||||
const [modalStep, setModalStep] = useState<ModalStep>("preview");
|
const [modalStep, setModalStep] = useState<ModalStep>("preview");
|
||||||
|
|
||||||
|
|
@ -76,11 +82,13 @@ export function TemplatePreview() {
|
||||||
const [purpose, setPurpose] = useState("");
|
const [purpose, setPurpose] = useState("");
|
||||||
const [attachments, setAttachments] = useState<string[]>([]);
|
const [attachments, setAttachments] = useState<string[]>([]);
|
||||||
const [projectFolder, setProjectFolder] = useState<string | null>(null);
|
const [projectFolder, setProjectFolder] = useState<string | null>(null);
|
||||||
const [projectName, setProjectName] = useState(randomProjectName);
|
const [projectName, setProjectName] = useState("");
|
||||||
|
const [projectNameError, setProjectNameError] = useState("");
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [refFilesOpen, setRefFilesOpen] = useState(false);
|
const [refFilesOpen, setRefFilesOpen] = useState(false);
|
||||||
const [locationOpen, setLocationOpen] = useState(false);
|
const [locationOpen, setLocationOpen] = useState(false);
|
||||||
|
const projectNameRef = useRef<HTMLInputElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
// ── Store access ──
|
// ── Store access ──
|
||||||
|
|
@ -100,7 +108,9 @@ export function TemplatePreview() {
|
||||||
setIsLandscape(false);
|
setIsLandscape(false);
|
||||||
setError(false);
|
setError(false);
|
||||||
if (docIdRef.current > 0) {
|
if (docIdRef.current > 0) {
|
||||||
getMupdfClient().closeDocument(docIdRef.current).catch(() => {});
|
getMupdfClient()
|
||||||
|
.closeDocument(docIdRef.current)
|
||||||
|
.catch(() => {});
|
||||||
docIdRef.current = 0;
|
docIdRef.current = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -114,7 +124,8 @@ export function TemplatePreview() {
|
||||||
setModalStep("preview");
|
setModalStep("preview");
|
||||||
setPurpose("");
|
setPurpose("");
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
setProjectName(randomProjectName());
|
setProjectName("");
|
||||||
|
setProjectNameError("");
|
||||||
setRefFilesOpen(false);
|
setRefFilesOpen(false);
|
||||||
setLocationOpen(false);
|
setLocationOpen(false);
|
||||||
}
|
}
|
||||||
|
|
@ -126,14 +137,22 @@ export function TemplatePreview() {
|
||||||
if (lastProjectFolder) {
|
if (lastProjectFolder) {
|
||||||
setProjectFolder(lastProjectFolder);
|
setProjectFolder(lastProjectFolder);
|
||||||
} else {
|
} else {
|
||||||
documentDir().then((dir) => setProjectFolder(dir)).catch(() => {});
|
homeDir()
|
||||||
|
.then((home) => join(home, "Documents", "ClaudePrism"))
|
||||||
|
.then(async (dir) => {
|
||||||
|
await mkdir(dir, { recursive: true }).catch(() => {});
|
||||||
|
setProjectFolder(dir);
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
console.warn("Failed to resolve default project folder:", err),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Auto-focus textarea in details step
|
// Auto-focus textarea in details step
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (modalStep === "details") {
|
if (modalStep === "details") {
|
||||||
const timer = setTimeout(() => textareaRef.current?.focus(), 150);
|
const timer = setTimeout(() => projectNameRef.current?.focus(), 150);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [modalStep]);
|
}, [modalStep]);
|
||||||
|
|
@ -187,7 +206,7 @@ export function TemplatePreview() {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (gen !== loadGenRef.current) return;
|
if (gen !== loadGenRef.current) return;
|
||||||
console.warn("[template-preview] load error:", err);
|
log.warn("load error", { error: String(err) });
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError(true);
|
setError(true);
|
||||||
}
|
}
|
||||||
|
|
@ -196,7 +215,13 @@ export function TemplatePreview() {
|
||||||
|
|
||||||
// ── Render current page ──
|
// ── Render current page ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (docIdRef.current <= 0 || numPages === 0 || !canvasRef.current || !containerRef.current) return;
|
if (
|
||||||
|
docIdRef.current <= 0 ||
|
||||||
|
numPages === 0 ||
|
||||||
|
!canvasRef.current ||
|
||||||
|
!containerRef.current
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
const pageIndex = currentPage - 1;
|
const pageIndex = currentPage - 1;
|
||||||
const size = pageSizesRef.current[pageIndex];
|
const size = pageSizesRef.current[pageIndex];
|
||||||
|
|
@ -220,18 +245,21 @@ export function TemplatePreview() {
|
||||||
const dpi = (displayW / size.width) * 72 * dpr;
|
const dpi = (displayW / size.width) * 72 * dpr;
|
||||||
|
|
||||||
const client = getMupdfClient();
|
const client = getMupdfClient();
|
||||||
client.drawPage(docIdRef.current, pageIndex, dpi).then((imageData) => {
|
client
|
||||||
const canvas = canvasRef.current;
|
.drawPage(docIdRef.current, pageIndex, dpi)
|
||||||
if (!canvas) return;
|
.then((imageData) => {
|
||||||
canvas.width = imageData.width;
|
const canvas = canvasRef.current;
|
||||||
canvas.height = imageData.height;
|
if (!canvas) return;
|
||||||
canvas.style.width = `${displayW}px`;
|
canvas.width = imageData.width;
|
||||||
canvas.style.height = `${displayH}px`;
|
canvas.height = imageData.height;
|
||||||
const ctx = canvas.getContext("2d")!;
|
canvas.style.width = `${displayW}px`;
|
||||||
ctx.putImageData(imageData, 0, 0);
|
canvas.style.height = `${displayH}px`;
|
||||||
}).catch((err) => {
|
const ctx = canvas.getContext("2d")!;
|
||||||
console.warn("[template-preview] render error:", err);
|
ctx.putImageData(imageData, 0, 0);
|
||||||
});
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log.warn("render error", { error: String(err) });
|
||||||
|
});
|
||||||
}, [currentPage, numPages, isLandscape]);
|
}, [currentPage, numPages, isLandscape]);
|
||||||
|
|
||||||
// ── Page navigation ──
|
// ── Page navigation ──
|
||||||
|
|
@ -248,8 +276,13 @@ export function TemplatePreview() {
|
||||||
if (!previewTemplateId || modalStep !== "preview") return;
|
if (!previewTemplateId || modalStep !== "preview") return;
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === "ArrowLeft") { e.preventDefault(); goToPrevPage(); }
|
if (e.key === "ArrowLeft") {
|
||||||
else if (e.key === "ArrowRight") { e.preventDefault(); goToNextPage(); }
|
e.preventDefault();
|
||||||
|
goToPrevPage();
|
||||||
|
} else if (e.key === "ArrowRight") {
|
||||||
|
e.preventDefault();
|
||||||
|
goToNextPage();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
|
@ -272,16 +305,25 @@ export function TemplatePreview() {
|
||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
const paths = (event.payload as { paths: string[] }).paths;
|
const paths = (event.payload as { paths: string[] }).paths;
|
||||||
if (paths?.length > 0) {
|
if (paths?.length > 0) {
|
||||||
setAttachments((prev) => [...prev, ...paths.filter((p) => !prev.includes(p))]);
|
setAttachments((prev) => [
|
||||||
|
...prev,
|
||||||
|
...paths.filter((p) => !prev.includes(p)),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
} else if (type === "leave") {
|
} else if (type === "leave") {
|
||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then((fn) => { if (cancelled) fn(); else unlisten = fn; })
|
.then((fn) => {
|
||||||
|
if (cancelled) fn();
|
||||||
|
else unlisten = fn;
|
||||||
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
return () => { cancelled = true; unlisten?.(); };
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unlisten?.();
|
||||||
|
};
|
||||||
}, [modalStep]);
|
}, [modalStep]);
|
||||||
|
|
||||||
// ── File handlers ──
|
// ── File handlers ──
|
||||||
|
|
@ -289,14 +331,13 @@ export function TemplatePreview() {
|
||||||
const selected = await open({
|
const selected = await open({
|
||||||
multiple: true,
|
multiple: true,
|
||||||
title: "Add Reference Files",
|
title: "Add Reference Files",
|
||||||
filters: [{
|
|
||||||
name: "Documents & Images",
|
|
||||||
extensions: ["pdf", "tex", "bib", "txt", "md", "png", "jpg", "jpeg", "gif", "svg", "csv", "tsv", "json"],
|
|
||||||
}],
|
|
||||||
});
|
});
|
||||||
if (selected) {
|
if (selected) {
|
||||||
const paths = Array.isArray(selected) ? selected : [selected];
|
const paths = Array.isArray(selected) ? selected : [selected];
|
||||||
setAttachments((prev) => [...prev, ...paths.filter((p) => !prev.includes(p))]);
|
setAttachments((prev) => [
|
||||||
|
...prev,
|
||||||
|
...paths.filter((p) => !prev.includes(p)),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -305,7 +346,11 @@ export function TemplatePreview() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChooseFolder = useCallback(async () => {
|
const handleChooseFolder = useCallback(async () => {
|
||||||
const selected = await open({ directory: true, multiple: false, title: "Choose Location for New Project" });
|
const selected = await open({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
title: "Choose Location for New Project",
|
||||||
|
});
|
||||||
if (selected) {
|
if (selected) {
|
||||||
setProjectFolder(selected);
|
setProjectFolder(selected);
|
||||||
setLastProjectFolder(selected);
|
setLastProjectFolder(selected);
|
||||||
|
|
@ -314,12 +359,21 @@ export function TemplatePreview() {
|
||||||
|
|
||||||
// ── Create project ──
|
// ── Create project ──
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!template || !projectFolder || !projectName.trim()) return;
|
const name = normalizeProjectName(projectName);
|
||||||
|
const nameError = getProjectNameError(projectName);
|
||||||
|
if (!template || !projectFolder || nameError) {
|
||||||
|
setProjectNameError(nameError ?? "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const projectPath = await join(projectFolder, projectName.trim());
|
const projectPath = await join(projectFolder, name);
|
||||||
await mkdir(projectPath, { recursive: true }).catch(() => {});
|
if (await exists(projectPath)) {
|
||||||
|
setProjectNameError("A folder with this name already exists here");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await mkdir(projectPath, { recursive: true });
|
||||||
|
|
||||||
const mainTexPath = await join(projectPath, template.mainFileName);
|
const mainTexPath = await join(projectPath, template.mainFileName);
|
||||||
const mainExists = await exists(mainTexPath);
|
const mainExists = await exists(mainTexPath);
|
||||||
|
|
@ -335,16 +389,13 @@ export function TemplatePreview() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachments.length > 0) {
|
const referenceFiles =
|
||||||
const attachmentsDir = await join(projectPath, "attachments");
|
attachments.length > 0
|
||||||
await mkdir(attachmentsDir, { recursive: true }).catch(() => {});
|
? await importReferenceFiles(projectPath, attachments)
|
||||||
}
|
: [];
|
||||||
|
|
||||||
if (purpose.trim()) {
|
if (purpose.trim()) {
|
||||||
const attachmentNames = attachments.map((p) => p.split("/").pop()).filter(Boolean);
|
const attachmentSection = buildReferenceFilesSection(referenceFiles);
|
||||||
const attachmentSection = attachmentNames.length > 0
|
|
||||||
? `\n### Reference Files\n${attachmentNames.map((n) => `- \`${n}\``).join("\n")}\n\nPlease review them and incorporate relevant information.\n`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const prompt = [
|
const prompt = [
|
||||||
`## New ${template.name} Project`,
|
`## New ${template.name} Project`,
|
||||||
|
|
@ -371,46 +422,49 @@ export function TemplatePreview() {
|
||||||
addRecentProject(projectPath);
|
addRecentProject(projectPath);
|
||||||
await openProject(projectPath);
|
await openProject(projectPath);
|
||||||
|
|
||||||
if (attachments.length > 0) {
|
|
||||||
await useDocumentStore.getState().importFiles(attachments, "attachments");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal on success
|
// Close modal on success
|
||||||
closePreview();
|
closePreview();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to create project:", err);
|
console.error("Failed to create project:", err);
|
||||||
|
toast.error("Failed to create project", {
|
||||||
|
description: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const canCreate = template && projectFolder && projectName.trim();
|
const canCreate = Boolean(
|
||||||
|
template && projectFolder && !getProjectNameError(projectName),
|
||||||
|
);
|
||||||
|
|
||||||
if (!template) return null;
|
if (!template) return null;
|
||||||
|
|
||||||
// ── Modal width depends on step ──
|
// ── Modal width depends on step ──
|
||||||
const modalWidth = modalStep === "preview"
|
const modalWidth =
|
||||||
? isLandscape
|
modalStep === "preview"
|
||||||
? "w-[min(72rem,calc(100vw-4rem))]"
|
? isLandscape
|
||||||
: "w-[min(48rem,calc(100vw-6rem))]"
|
? "w-[min(72rem,calc(100vw-4rem))]"
|
||||||
: "w-[min(32rem,calc(100vw-4rem))]";
|
: "w-[min(48rem,calc(100vw-6rem))]"
|
||||||
|
: "w-[min(32rem,calc(100vw-4rem))]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={!!previewTemplateId} onOpenChange={handleOpenChange}>
|
<Dialog open={!!previewTemplateId} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
className={`flex max-w-none sm:max-w-none flex-col gap-0 overflow-hidden p-0 transition-[width] duration-300 ${modalWidth} ${modalStep === "preview" ? "h-[70vh]" : "max-h-[80vh]"}`}
|
className={`flex max-w-none flex-col gap-0 overflow-hidden p-0 transition-[width] duration-300 sm:max-w-none ${modalWidth} ${modalStep === "preview" ? "h-[70vh]" : "max-h-[80vh]"}`}
|
||||||
>
|
>
|
||||||
{modalStep === "preview" ? (
|
{modalStep === "preview" ? (
|
||||||
/* ═══════════════════ PREVIEW STEP ═══════════════════ */
|
/* ═══════════════════ PREVIEW STEP ═══════════════════ */
|
||||||
<>
|
<>
|
||||||
<DialogHeader className="shrink-0 border-b border-border px-6 py-3">
|
<DialogHeader className="shrink-0 border-border border-b px-6 py-3">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<DialogTitle className="text-sm">{template.name}</DialogTitle>
|
<DialogTitle className="text-sm">{template.name}</DialogTitle>
|
||||||
<DialogDescription className="mt-0.5 truncate text-xs">
|
<DialogDescription className="mt-0.5 truncate text-xs">
|
||||||
{template.description} — {template.documentClass}
|
{template.description} — {template.documentClass}
|
||||||
{template.packages.length > 0 && ` — ${template.packages.length} packages`}
|
{template.packages.length > 0 &&
|
||||||
|
` — ${template.packages.length} packages`}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
|
@ -428,7 +482,10 @@ export function TemplatePreview() {
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div className="relative flex flex-1 flex-col">
|
<div className="relative flex flex-1 flex-col">
|
||||||
<div ref={containerRef} className="flex flex-1 items-center justify-center overflow-hidden bg-muted/30 p-6">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex flex-1 items-center justify-center overflow-hidden bg-muted/30 p-6"
|
||||||
|
>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
|
@ -449,14 +506,26 @@ export function TemplatePreview() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{numPages > 0 && (
|
{numPages > 0 && (
|
||||||
<div className="flex shrink-0 items-center justify-center gap-3 border-t border-border bg-background py-2.5">
|
<div className="flex shrink-0 items-center justify-center gap-3 border-border border-t bg-background py-2.5">
|
||||||
<Button variant="ghost" size="icon" className="size-7" onClick={goToPrevPage} disabled={currentPage <= 1}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7"
|
||||||
|
onClick={goToPrevPage}
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
>
|
||||||
<ChevronLeftIcon className="size-4" />
|
<ChevronLeftIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="min-w-16 text-center text-xs tabular-nums text-muted-foreground">
|
<span className="min-w-16 text-center text-muted-foreground text-xs tabular-nums">
|
||||||
{numPages > 1 ? `${currentPage} / ${numPages}` : "1 page"}
|
{numPages > 1 ? `${currentPage} / ${numPages}` : "1 page"}
|
||||||
</span>
|
</span>
|
||||||
<Button variant="ghost" size="icon" className="size-7" onClick={goToNextPage} disabled={currentPage >= numPages}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7"
|
||||||
|
onClick={goToNextPage}
|
||||||
|
disabled={currentPage >= numPages}
|
||||||
|
>
|
||||||
<ChevronRightIcon className="size-4" />
|
<ChevronRightIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -468,12 +537,12 @@ export function TemplatePreview() {
|
||||||
/* ═══════════════════ DETAILS STEP ═══════════════════ */
|
/* ═══════════════════ DETAILS STEP ═══════════════════ */
|
||||||
<>
|
<>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<DialogHeader className="shrink-0 border-b border-border/60 px-5 py-3">
|
<DialogHeader className="shrink-0 border-border/60 border-b px-5 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-7 rounded-lg shrink-0"
|
className="size-7 shrink-0 rounded-lg"
|
||||||
onClick={() => setModalStep("preview")}
|
onClick={() => setModalStep("preview")}
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon className="size-4" />
|
<ArrowLeftIcon className="size-4" />
|
||||||
|
|
@ -490,12 +559,43 @@ export function TemplatePreview() {
|
||||||
{/* Form content */}
|
{/* Form content */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="space-y-4 p-5">
|
<div className="space-y-4 p-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-sm">Project name</span>
|
||||||
|
<p className="mt-0.5 text-muted-foreground text-xs leading-relaxed">
|
||||||
|
This becomes the folder name on disk.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
ref={projectNameRef}
|
||||||
|
placeholder="e.g., conference-paper"
|
||||||
|
value={projectName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setProjectName(e.target.value);
|
||||||
|
setProjectNameError("");
|
||||||
|
}}
|
||||||
|
onBlur={() =>
|
||||||
|
setProjectNameError(
|
||||||
|
getProjectNameError(projectName) ?? "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded-xl border-border/60 bg-card/30 text-sm focus-visible:bg-card/50"
|
||||||
|
/>
|
||||||
|
{projectNameError && (
|
||||||
|
<p className="text-destructive text-xs">
|
||||||
|
{projectNameError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{/* Purpose — hero element */}
|
{/* Purpose — hero element */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-semibold text-sm">What are you writing?</label>
|
<span className="font-semibold text-sm">
|
||||||
|
What are you writing?
|
||||||
|
</span>
|
||||||
<p className="mt-0.5 text-muted-foreground text-xs leading-relaxed">
|
<p className="mt-0.5 text-muted-foreground text-xs leading-relaxed">
|
||||||
Describe your document and Claude will generate tailored content.
|
Describe your document and Claude will generate tailored
|
||||||
|
content.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|
@ -509,7 +609,7 @@ export function TemplatePreview() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Collapsible sections */}
|
{/* Collapsible sections */}
|
||||||
<div className="rounded-xl border border-border/60 bg-card/30 divide-y divide-border/40 overflow-hidden">
|
<div className="divide-y divide-border/40 overflow-hidden rounded-xl border border-border/60 bg-card/30">
|
||||||
{/* Reference files */}
|
{/* Reference files */}
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -519,10 +619,12 @@ export function TemplatePreview() {
|
||||||
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-muted/50">
|
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-muted/50">
|
||||||
<FileTextIcon className="size-3 text-muted-foreground" />
|
<FileTextIcon className="size-3 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<span className="text-sm font-medium">Reference files</span>
|
<span className="font-medium text-sm">
|
||||||
|
Reference files
|
||||||
|
</span>
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary/15 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-primary">
|
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary/15 px-1.5 py-0.5 font-semibold text-[10px] text-primary leading-none">
|
||||||
{attachments.length}
|
{attachments.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -532,16 +634,18 @@ export function TemplatePreview() {
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{refFilesOpen && (
|
{refFilesOpen && (
|
||||||
<div className="px-4 pb-3 space-y-2.5">
|
<div className="space-y-2.5 px-4 pb-3">
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{attachments.map((path) => (
|
{attachments.map((path) => (
|
||||||
<div
|
<div
|
||||||
key={path}
|
key={path}
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-border/50 bg-muted/40 pl-2.5 pr-1.5 py-1 text-xs transition-colors hover:bg-muted/60"
|
className="flex items-center gap-1.5 rounded-lg border border-border/50 bg-muted/40 py-1 pr-1.5 pl-2.5 text-xs transition-colors hover:bg-muted/60"
|
||||||
>
|
>
|
||||||
<PaperclipIcon className="size-3 shrink-0 text-muted-foreground/70" />
|
<PaperclipIcon className="size-3 shrink-0 text-muted-foreground/70" />
|
||||||
<span className="max-w-30 truncate text-foreground/80">{path.split("/").pop()}</span>
|
<span className="max-w-30 truncate text-foreground/80">
|
||||||
|
{path.split(/[/\\]/).pop()}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemoveAttachment(path)}
|
onClick={() => handleRemoveAttachment(path)}
|
||||||
className="flex size-4 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 transition-colors hover:bg-destructive/10 hover:text-destructive"
|
className="flex size-4 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
|
@ -562,16 +666,20 @@ export function TemplatePreview() {
|
||||||
{isDragOver ? (
|
{isDragOver ? (
|
||||||
<>
|
<>
|
||||||
<UploadIcon className="size-4 text-primary" />
|
<UploadIcon className="size-4 text-primary" />
|
||||||
<span className="text-xs font-medium text-primary">Drop to add</span>
|
<span className="font-medium text-primary text-xs">
|
||||||
|
Drop to add
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<UploadIcon className="size-4 text-muted-foreground/40" />
|
<UploadIcon className="size-4 text-muted-foreground/40" />
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<span className="text-xs text-muted-foreground/70">Drag & drop or </span>
|
<span className="text-muted-foreground/70 text-xs">
|
||||||
|
Drag & drop or{" "}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddAttachments}
|
onClick={handleAddAttachments}
|
||||||
className="text-xs font-medium text-foreground/70 underline underline-offset-2 decoration-border hover:text-foreground transition-colors"
|
className="font-medium text-foreground/70 text-xs underline decoration-border underline-offset-2 transition-colors hover:text-foreground"
|
||||||
>
|
>
|
||||||
browse files
|
browse files
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -592,12 +700,15 @@ export function TemplatePreview() {
|
||||||
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-muted/50">
|
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-muted/50">
|
||||||
<MapPinIcon className="size-3 text-muted-foreground" />
|
<MapPinIcon className="size-3 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<span className="text-sm font-medium">Project location</span>
|
<span className="font-medium text-sm">
|
||||||
|
Project location
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!locationOpen && projectFolder && projectName.trim() && (
|
{!locationOpen && projectFolder && projectName.trim() && (
|
||||||
<span className="min-w-0 max-w-35 truncate rounded-md bg-muted/40 px-2 py-0.5 text-[11px] font-mono text-muted-foreground/60">
|
<span className="min-w-0 max-w-35 truncate rounded-md bg-muted/40 px-2 py-0.5 font-mono text-[11px] text-muted-foreground/60">
|
||||||
.../{projectFolder.split("/").pop()}/{projectName.trim()}
|
.../{projectFolder.split(/[/\\]/).pop()}/
|
||||||
|
{normalizeProjectName(projectName)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
|
|
@ -605,14 +716,13 @@ export function TemplatePreview() {
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{locationOpen && (
|
{locationOpen && (
|
||||||
<div className="px-4 pb-3 space-y-2">
|
<div className="space-y-2 px-4 pb-3">
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<p className="min-w-0 flex-1 truncate rounded-md bg-muted/30 px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground/60">
|
||||||
placeholder="Project name"
|
{projectFolder
|
||||||
value={projectName}
|
? `${projectFolder}/${normalizeProjectName(projectName) || "..."}`
|
||||||
onChange={(e) => setProjectName(e.target.value)}
|
: "Choose a location"}
|
||||||
className="flex-1 rounded-lg border-border/60 bg-background/50"
|
</p>
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -623,11 +733,6 @@ export function TemplatePreview() {
|
||||||
{projectFolder ? "Change" : "Choose"}
|
{projectFolder ? "Change" : "Choose"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{projectFolder && (
|
|
||||||
<p className="truncate rounded-md bg-muted/30 px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground/60">
|
|
||||||
{projectFolder}/{projectName.trim() || "..."}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -636,7 +741,7 @@ export function TemplatePreview() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create button — sticky footer */}
|
{/* Create button — sticky footer */}
|
||||||
<div className="shrink-0 border-t border-border/60 px-5 py-4">
|
<div className="shrink-0 border-border/60 border-t px-5 py-4">
|
||||||
<Button
|
<Button
|
||||||
className="w-full gap-2 rounded-xl font-semibold shadow-sm transition-all hover:shadow-md active:scale-[0.99]"
|
className="w-full gap-2 rounded-xl font-semibold shadow-sm transition-all hover:shadow-md active:scale-[0.99]"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,12 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
"inline-flex items-center rounded-md border px-2 py-0.5 font-medium text-xs transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: "border-transparent bg-primary text-primary-foreground",
|
||||||
"border-transparent bg-primary text-primary-foreground",
|
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||||
secondary:
|
|
||||||
"border-transparent bg-secondary text-secondary-foreground",
|
|
||||||
destructive:
|
destructive:
|
||||||
"border-transparent bg-destructive text-destructive-foreground",
|
"border-transparent bg-destructive text-destructive-foreground",
|
||||||
outline: "text-foreground",
|
outline: "text-foreground",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ function ContextMenuTrigger({
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||||
return (
|
return (
|
||||||
<ContextMenuPrimitive.Trigger
|
<ContextMenuPrimitive.Trigger
|
||||||
|
data-app-context-menu
|
||||||
data-slot="context-menu-trigger"
|
data-slot="context-menu-trigger"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|
@ -27,6 +28,7 @@ function ContextMenuContent({
|
||||||
return (
|
return (
|
||||||
<ContextMenuPrimitive.Portal>
|
<ContextMenuPrimitive.Portal>
|
||||||
<ContextMenuPrimitive.Content
|
<ContextMenuPrimitive.Content
|
||||||
|
data-app-context-menu
|
||||||
data-slot="context-menu-content"
|
data-slot="context-menu-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in",
|
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
|
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
|
|
@ -39,7 +37,7 @@ function DialogOverlay({
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in",
|
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/35 backdrop-blur-sm data-[state=closed]:animate-out data-[state=open]:animate-in dark:bg-black/45",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
|
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
|
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Label as LabelPrimitive } from "radix-ui";
|
import { Label as LabelPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
|
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Select as SelectPrimitive } from "radix-ui";
|
import { Select as SelectPrimitive } from "radix-ui";
|
||||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
|
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Separator as SeparatorPrimitive } from "radix-ui";
|
import { Separator as SeparatorPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
|
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Dialog as SheetPrimitive } from "radix-ui";
|
import { Dialog as SheetPrimitive } from "radix-ui";
|
||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CircleCheckIcon,
|
CircleCheckIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ function TabsTrigger({
|
||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
data-slot="tabs-trigger"
|
data-slot="tabs-trigger"
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 font-medium text-sm ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
|
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Toggle as TogglePrimitive } from "radix-ui";
|
import { Toggle as TogglePrimitive } from "radix-ui";
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
|
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,22 +71,22 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="w-full max-w-[34rem] overflow-hidden sm:max-w-[34rem]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex min-w-0 items-center gap-2 pr-8">
|
||||||
<TerminalIcon className="size-5" />
|
<TerminalIcon className="size-5 shrink-0" />
|
||||||
Python Environment (uv)
|
<span className="min-w-0 truncate">Python Environment (uv)</span>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Manage the Python virtual environment for this project.
|
Manage the Python virtual environment for this project.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-2">
|
<div className="min-w-0 space-y-4 py-2">
|
||||||
{/* uv status */}
|
{/* uv status */}
|
||||||
<div className="flex items-center gap-3 rounded-lg border p-3">
|
<div className="flex min-w-0 items-center gap-3 overflow-hidden rounded-lg border p-3">
|
||||||
<StatusIcon status={status} isInstalling={isInstalling} />
|
<StatusIcon status={status} isInstalling={isInstalling} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
<div className="font-medium text-sm">
|
<div className="font-medium text-sm">
|
||||||
{status === "checking"
|
{status === "checking"
|
||||||
? "Checking uv..."
|
? "Checking uv..."
|
||||||
|
|
@ -97,12 +97,14 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
|
||||||
: "Error"}
|
: "Error"}
|
||||||
</div>
|
</div>
|
||||||
{version && (
|
{version && (
|
||||||
<div className="text-muted-foreground text-xs truncate">
|
<div className="truncate text-muted-foreground text-xs">
|
||||||
{version}
|
{version}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-destructive text-xs mt-1">{error}</div>
|
<div className="mt-1 break-words text-destructive text-xs">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{status === "not-installed" && !isInstalling && (
|
{status === "not-installed" && !isInstalling && (
|
||||||
|
|
@ -121,29 +123,37 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
|
||||||
|
|
||||||
{/* venv status — only show when uv is ready */}
|
{/* venv status — only show when uv is ready */}
|
||||||
{status === "ready" && (
|
{status === "ready" && (
|
||||||
<div className="flex items-center gap-3 rounded-lg border p-3">
|
<div className="flex min-w-0 items-center gap-3 overflow-hidden rounded-lg border p-3">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex size-8 items-center justify-center rounded-full",
|
"flex size-8 shrink-0 items-center justify-center rounded-full",
|
||||||
venvReady
|
venvReady
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-accent text-accent-foreground"
|
||||||
: "bg-muted text-muted-foreground"
|
: "bg-muted text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FolderIcon className="size-4" />
|
<FolderIcon className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
<div className="font-medium text-sm">
|
<div className="font-medium text-sm">
|
||||||
{venvReady ? "Virtual Environment Active" : "No Virtual Environment"}
|
{venvReady
|
||||||
|
? "Virtual Environment Active"
|
||||||
|
: "No Virtual Environment"}
|
||||||
</div>
|
</div>
|
||||||
{venvPath && (
|
{venvPath && (
|
||||||
<div className="text-muted-foreground text-xs truncate" title={venvPath}>
|
<div
|
||||||
|
className="max-w-full break-all text-muted-foreground text-xs leading-snug"
|
||||||
|
title={venvPath}
|
||||||
|
>
|
||||||
{venvPath}
|
{venvPath}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{pythonPath && (
|
{pythonPath && (
|
||||||
<div className="text-muted-foreground text-xs truncate" title={pythonPath}>
|
<div
|
||||||
Python: {pythonPath.split("/").pop() || pythonPath.split("\\").pop()}
|
className="truncate text-muted-foreground text-xs"
|
||||||
|
title={pythonPath}
|
||||||
|
>
|
||||||
|
Python: {pythonPath.split(/[/\\]/).pop()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -157,10 +167,12 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
|
||||||
|
|
||||||
{/* Info text */}
|
{/* Info text */}
|
||||||
{status === "ready" && venvReady && (
|
{status === "ready" && venvReady && (
|
||||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
<p className="max-w-full break-words text-muted-foreground text-xs leading-relaxed">
|
||||||
Claude Code will automatically use this environment when running
|
Claude Code and ClaudePrism terminal tools use this environment
|
||||||
Python code. Use <code className="text-foreground">uv pip install</code> to
|
when running Python code. OpenAI-compatible providers use it
|
||||||
add packages.
|
through PowerShell/Bash tool calls. Use{" "}
|
||||||
|
<code className="text-foreground">uv pip install</code> to add
|
||||||
|
packages.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -178,28 +190,28 @@ function StatusIcon({
|
||||||
}) {
|
}) {
|
||||||
if (isInstalling || status === "checking") {
|
if (isInstalling || status === "checking") {
|
||||||
return (
|
return (
|
||||||
<div className="flex size-8 items-center justify-center rounded-full bg-muted">
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||||
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
|
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (status === "ready") {
|
if (status === "ready") {
|
||||||
return (
|
return (
|
||||||
<div className="flex size-8 items-center justify-center rounded-full bg-accent text-accent-foreground">
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-accent text-accent-foreground">
|
||||||
<CheckCircle2Icon className="size-4" />
|
<CheckCircle2Icon className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (status === "error") {
|
if (status === "error") {
|
||||||
return (
|
return (
|
||||||
<div className="flex size-8 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||||
<AlertCircleIcon className="size-4" />
|
<AlertCircleIcon className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// not-installed
|
// not-installed
|
||||||
return (
|
return (
|
||||||
<div className="flex size-8 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||||
<TerminalIcon className="size-4" />
|
<TerminalIcon className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import vscodeIcon from "@/assets/vscode.svg";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -33,6 +34,7 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useDocumentStore } from "@/stores/document-store";
|
import { useDocumentStore } from "@/stores/document-store";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
interface EditorInfo {
|
interface EditorInfo {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -50,6 +52,28 @@ const ZOOM_OPTIONS = [
|
||||||
{ value: "4", label: "400%" },
|
{ value: "4", label: "400%" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function OpenEditorIcon({ editor }: { editor: EditorInfo }) {
|
||||||
|
if (editor.id === "vscode") {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={vscodeIcon}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
draggable={false}
|
||||||
|
className="size-5"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ExternalLinkIcon className="size-4" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOpenEditorButtonClassName(editor: EditorInfo) {
|
||||||
|
return editor.id === "vscode"
|
||||||
|
? "h-7 w-7 border border-border/70 bg-muted/30 p-1 hover:bg-muted/50"
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
interface EditorToolbarProps {
|
interface EditorToolbarProps {
|
||||||
editorView: RefObject<EditorView | null>;
|
editorView: RefObject<EditorView | null>;
|
||||||
fileType?: "tex" | "image";
|
fileType?: "tex" | "image";
|
||||||
|
|
@ -67,6 +91,9 @@ export function EditorToolbar({
|
||||||
cropMode,
|
cropMode,
|
||||||
onCropToggle,
|
onCropToggle,
|
||||||
}: EditorToolbarProps) {
|
}: EditorToolbarProps) {
|
||||||
|
const vimMode = useSettingsStore((s) => s.vimMode);
|
||||||
|
const setVimMode = useSettingsStore((s) => s.setVimMode);
|
||||||
|
|
||||||
const fileName = useDocumentStore((s) => {
|
const fileName = useDocumentStore((s) => {
|
||||||
const activeFile = s.files.find((f) => f.id === s.activeFileId);
|
const activeFile = s.files.find((f) => f.id === s.activeFileId);
|
||||||
return activeFile?.name ?? "main.tex";
|
return activeFile?.name ?? "main.tex";
|
||||||
|
|
@ -80,7 +107,9 @@ export function EditorToolbar({
|
||||||
const [editors, setEditors] = useState<EditorInfo[]>([]);
|
const [editors, setEditors] = useState<EditorInfo[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
invoke<EditorInfo[]>("detect_editors").then(setEditors).catch(() => {});
|
invoke<EditorInfo[]>("detect_editors")
|
||||||
|
.then(setEditors)
|
||||||
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openInEditor = useCallback(
|
const openInEditor = useCallback(
|
||||||
|
|
@ -130,10 +159,13 @@ export function EditorToolbar({
|
||||||
|
|
||||||
if (fileType === "image") {
|
if (fileType === "image") {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between border-border border-b bg-muted/30 px-2 pt-[var(--titlebar-height)] h-[calc(36px+var(--titlebar-height))]">
|
<div className="flex h-[calc(var(--workspace-topbar-height)+var(--titlebar-height))] min-w-0 items-center justify-between border-border border-b bg-muted/30 px-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex min-w-0 max-w-[min(18rem,35vw)] items-center gap-1.5">
|
||||||
<ImageIcon className="size-4 text-muted-foreground" />
|
<ImageIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
<span className="font-medium text-muted-foreground text-sm">
|
<span
|
||||||
|
className="min-w-0 truncate font-medium text-muted-foreground text-sm"
|
||||||
|
title={activeFilePath ?? fileName}
|
||||||
|
>
|
||||||
{fileName}
|
{fileName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -190,8 +222,9 @@ export function EditorToolbar({
|
||||||
<TooltipIconButton
|
<TooltipIconButton
|
||||||
tooltip={`Open in ${editors[0].name}`}
|
tooltip={`Open in ${editors[0].name}`}
|
||||||
onClick={() => openInEditor(editors[0].id)}
|
onClick={() => openInEditor(editors[0].id)}
|
||||||
|
className={getOpenEditorButtonClassName(editors[0])}
|
||||||
>
|
>
|
||||||
<ExternalLinkIcon className="size-4" />
|
<OpenEditorIcon editor={editors[0]} />
|
||||||
</TooltipIconButton>
|
</TooltipIconButton>
|
||||||
)}
|
)}
|
||||||
{editors.length > 1 && (
|
{editors.length > 1 && (
|
||||||
|
|
@ -224,12 +257,17 @@ export function EditorToolbar({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 border-border border-b bg-muted/30 px-2 pt-[var(--titlebar-height)] h-[calc(36px+var(--titlebar-height))]">
|
<div className="flex h-[calc(var(--workspace-topbar-height)+var(--titlebar-height))] min-w-0 items-center gap-1 border-border border-b bg-muted/30 px-2">
|
||||||
<FileTextIcon className="size-4 text-muted-foreground" />
|
<div className="flex min-w-0 max-w-[min(18rem,35vw)] shrink items-center gap-1.5">
|
||||||
<span className="mr-2 font-medium text-muted-foreground text-sm">
|
<FileTextIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
{fileName}
|
<span
|
||||||
</span>
|
className="min-w-0 truncate font-medium text-muted-foreground text-sm"
|
||||||
<div className="mx-2 h-4 w-px bg-border" />
|
title={activeFilePath ?? fileName}
|
||||||
|
>
|
||||||
|
{fileName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mx-2 h-4 w-px shrink-0 bg-border" />
|
||||||
<TooltipIconButton
|
<TooltipIconButton
|
||||||
tooltip="Bold (\\textbf)"
|
tooltip="Bold (\\textbf)"
|
||||||
onClick={() => insertText("\\textbf{", "}")}
|
onClick={() => insertText("\\textbf{", "}")}
|
||||||
|
|
@ -287,13 +325,24 @@ export function EditorToolbar({
|
||||||
>
|
>
|
||||||
<BookMarkedIcon className="size-4" />
|
<BookMarkedIcon className="size-4" />
|
||||||
</TooltipIconButton>
|
</TooltipIconButton>
|
||||||
|
<div className="mx-2 h-4 w-px bg-border" />
|
||||||
|
<Button
|
||||||
|
variant={vimMode ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 font-mono text-xs"
|
||||||
|
onClick={() => setVimMode(!vimMode)}
|
||||||
|
title="Toggle Vim mode"
|
||||||
|
>
|
||||||
|
VIM
|
||||||
|
</Button>
|
||||||
<div data-tauri-drag-region className="flex-1 self-stretch" />
|
<div data-tauri-drag-region className="flex-1 self-stretch" />
|
||||||
{editors.length === 1 && (
|
{editors.length === 1 && (
|
||||||
<TooltipIconButton
|
<TooltipIconButton
|
||||||
tooltip={`Open in ${editors[0].name}`}
|
tooltip={`Open in ${editors[0].name}`}
|
||||||
onClick={() => openInEditor(editors[0].id)}
|
onClick={() => openInEditor(editors[0].id)}
|
||||||
|
className={getOpenEditorButtonClassName(editors[0])}
|
||||||
>
|
>
|
||||||
<ExternalLinkIcon className="size-4" />
|
<OpenEditorIcon editor={editors[0]} />
|
||||||
</TooltipIconButton>
|
</TooltipIconButton>
|
||||||
)}
|
)}
|
||||||
{editors.length > 1 && (
|
{editors.length > 1 && (
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ImageIcon, CheckIcon, XIcon } from "lucide-react";
|
import { CheckIcon, XIcon } from "lucide-react";
|
||||||
import { writeFile } from "@tauri-apps/plugin-fs";
|
import { writeFile } from "@tauri-apps/plugin-fs";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useDocumentStore, type ProjectFile } from "@/stores/document-store";
|
import { useDocumentStore, type ProjectFile } from "@/stores/document-store";
|
||||||
|
import { LOCAL_ZOOM_SHORTCUTS_ATTR } from "@/lib/app-zoom";
|
||||||
import { getAssetUrl } from "@/lib/tauri/fs";
|
import { getAssetUrl } from "@/lib/tauri/fs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
|
@ -50,9 +51,17 @@ export function ImagePreview({
|
||||||
|
|
||||||
// Crop state
|
// Crop state
|
||||||
const [cropRect, setCropRect] = useState<CropRect | null>(null);
|
const [cropRect, setCropRect] = useState<CropRect | null>(null);
|
||||||
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null);
|
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(
|
||||||
const [activeHandle, setActiveHandle] = useState<HandleId | "move" | null>(null);
|
null,
|
||||||
const [handleDragStart, setHandleDragStart] = useState<{ x: number; y: number; rect: CropRect } | null>(null);
|
);
|
||||||
|
const [activeHandle, setActiveHandle] = useState<HandleId | "move" | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [handleDragStart, setHandleDragStart] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
rect: CropRect;
|
||||||
|
} | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
// Reset crop rect when exiting crop mode
|
// Reset crop rect when exiting crop mode
|
||||||
|
|
@ -121,18 +130,15 @@ export function ImagePreview({
|
||||||
}, [scale, onScaleChange, cropMode]);
|
}, [scale, onScaleChange, cropMode]);
|
||||||
|
|
||||||
// Get coordinates relative to the displayed image
|
// Get coordinates relative to the displayed image
|
||||||
const getImageRelativeCoords = useCallback(
|
const getImageRelativeCoords = useCallback((e: React.MouseEvent) => {
|
||||||
(e: React.MouseEvent) => {
|
const img = imgRef.current;
|
||||||
const img = imgRef.current;
|
if (!img) return null;
|
||||||
if (!img) return null;
|
const rect = img.getBoundingClientRect();
|
||||||
const rect = img.getBoundingClientRect();
|
return {
|
||||||
return {
|
x: Math.max(0, Math.min(rect.width, e.clientX - rect.left)),
|
||||||
x: Math.max(0, Math.min(rect.width, e.clientX - rect.left)),
|
y: Math.max(0, Math.min(rect.height, e.clientY - rect.top)),
|
||||||
y: Math.max(0, Math.min(rect.height, e.clientY - rect.top)),
|
};
|
||||||
};
|
}, []);
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Crop drag: new selection ---
|
// --- Crop drag: new selection ---
|
||||||
const handleCropMouseDown = useCallback(
|
const handleCropMouseDown = useCallback(
|
||||||
|
|
@ -151,7 +157,11 @@ export function ImagePreview({
|
||||||
coords.y <= cropRect.y + cropRect.h
|
coords.y <= cropRect.y + cropRect.h
|
||||||
) {
|
) {
|
||||||
setActiveHandle("move");
|
setActiveHandle("move");
|
||||||
setHandleDragStart({ x: coords.x, y: coords.y, rect: { ...cropRect } });
|
setHandleDragStart({
|
||||||
|
x: coords.x,
|
||||||
|
y: coords.y,
|
||||||
|
rect: { ...cropRect },
|
||||||
|
});
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -230,11 +240,21 @@ export function ImagePreview({
|
||||||
setCropRect({ x, y, w, h });
|
setCropRect({ x, y, w, h });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[cropMode, dragStart, activeHandle, handleDragStart, getImageRelativeCoords],
|
[
|
||||||
|
cropMode,
|
||||||
|
dragStart,
|
||||||
|
activeHandle,
|
||||||
|
handleDragStart,
|
||||||
|
getImageRelativeCoords,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCropMouseUp = useCallback(() => {
|
const handleCropMouseUp = useCallback(() => {
|
||||||
if (dragStart && cropRect && (cropRect.w < MIN_CROP_SIZE || cropRect.h < MIN_CROP_SIZE)) {
|
if (
|
||||||
|
dragStart &&
|
||||||
|
cropRect &&
|
||||||
|
(cropRect.w < MIN_CROP_SIZE || cropRect.h < MIN_CROP_SIZE)
|
||||||
|
) {
|
||||||
setCropRect(null);
|
setCropRect(null);
|
||||||
}
|
}
|
||||||
setDragStart(null);
|
setDragStart(null);
|
||||||
|
|
@ -328,14 +348,20 @@ export function ImagePreview({
|
||||||
// Use dataUrl if available (in-memory), otherwise fall back to asset URL (large images)
|
// Use dataUrl if available (in-memory), otherwise fall back to asset URL (large images)
|
||||||
const imageSrc = file.dataUrl || getAssetUrl(file.absolutePath);
|
const imageSrc = file.dataUrl || getAssetUrl(file.absolutePath);
|
||||||
// Crop requires dataUrl (canvas manipulation needs same-origin data)
|
// Crop requires dataUrl (canvas manipulation needs same-origin data)
|
||||||
const canCrop = !!file.dataUrl;
|
const _canCrop = !!file.dataUrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
{...{ [LOCAL_ZOOM_SHORTCUTS_ATTR]: "true" }}
|
||||||
className="relative h-full overflow-auto bg-muted/50 p-4 outline-none"
|
className="relative h-full overflow-auto bg-muted/50 p-4 outline-none"
|
||||||
style={cropMode ? { cursor: cropRect && !dragStart ? "default" : "crosshair" } : undefined}
|
style={
|
||||||
|
cropMode
|
||||||
|
? { cursor: cropRect && !dragStart ? "default" : "crosshair" }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onMouseDownCapture={() => containerRef.current?.focus()}
|
||||||
onMouseMove={cropMode ? handleCropMouseMove : undefined}
|
onMouseMove={cropMode ? handleCropMouseMove : undefined}
|
||||||
onMouseUp={cropMode ? handleCropMouseUp : undefined}
|
onMouseUp={cropMode ? handleCropMouseUp : undefined}
|
||||||
onMouseLeave={cropMode ? handleCropMouseUp : undefined}
|
onMouseLeave={cropMode ? handleCropMouseUp : undefined}
|
||||||
|
|
@ -348,7 +374,10 @@ export function ImagePreview({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Wrapper width = scale * 100% of container → CSS handles fit, no JS needed */}
|
{/* Wrapper width = scale * 100% of container → CSS handles fit, no JS needed */}
|
||||||
<div className="relative" style={{ width: `${scale * 100}%`, margin: "0 auto" }}>
|
<div
|
||||||
|
className="relative"
|
||||||
|
style={{ width: `${scale * 100}%`, margin: "0 auto" }}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
ref={imgRef}
|
ref={imgRef}
|
||||||
src={imageSrc}
|
src={imageSrc}
|
||||||
|
|
@ -363,13 +392,40 @@ export function ImagePreview({
|
||||||
<>
|
<>
|
||||||
{/* Dark overlay: 4 divs around the crop area */}
|
{/* Dark overlay: 4 divs around the crop area */}
|
||||||
{/* Top */}
|
{/* Top */}
|
||||||
<div className="pointer-events-none absolute z-10 bg-black/50" style={{ left: 0, top: 0, right: 0, height: cropRect.y }} />
|
<div
|
||||||
|
className="pointer-events-none absolute z-10 bg-black/50"
|
||||||
|
style={{ left: 0, top: 0, right: 0, height: cropRect.y }}
|
||||||
|
/>
|
||||||
{/* Bottom */}
|
{/* Bottom */}
|
||||||
<div className="pointer-events-none absolute z-10 bg-black/50" style={{ left: 0, top: cropRect.y + cropRect.h, right: 0, bottom: 0 }} />
|
<div
|
||||||
|
className="pointer-events-none absolute z-10 bg-black/50"
|
||||||
|
style={{
|
||||||
|
left: 0,
|
||||||
|
top: cropRect.y + cropRect.h,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/* Left */}
|
{/* Left */}
|
||||||
<div className="pointer-events-none absolute z-10 bg-black/50" style={{ left: 0, top: cropRect.y, width: cropRect.x, height: cropRect.h }} />
|
<div
|
||||||
|
className="pointer-events-none absolute z-10 bg-black/50"
|
||||||
|
style={{
|
||||||
|
left: 0,
|
||||||
|
top: cropRect.y,
|
||||||
|
width: cropRect.x,
|
||||||
|
height: cropRect.h,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/* Right */}
|
{/* Right */}
|
||||||
<div className="pointer-events-none absolute z-10 bg-black/50" style={{ left: cropRect.x + cropRect.w, top: cropRect.y, right: 0, height: cropRect.h }} />
|
<div
|
||||||
|
className="pointer-events-none absolute z-10 bg-black/50"
|
||||||
|
style={{
|
||||||
|
left: cropRect.x + cropRect.w,
|
||||||
|
top: cropRect.y,
|
||||||
|
right: 0,
|
||||||
|
height: cropRect.h,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Crop border */}
|
{/* Crop border */}
|
||||||
<div
|
<div
|
||||||
|
|
@ -388,7 +444,11 @@ export function ImagePreview({
|
||||||
const coords = getImageRelativeCoords(e);
|
const coords = getImageRelativeCoords(e);
|
||||||
if (!coords) return;
|
if (!coords) return;
|
||||||
setActiveHandle("move");
|
setActiveHandle("move");
|
||||||
setHandleDragStart({ x: coords.x, y: coords.y, rect: { ...cropRect } });
|
setHandleDragStart({
|
||||||
|
x: coords.x,
|
||||||
|
y: coords.y,
|
||||||
|
rect: { ...cropRect },
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Resize handles */}
|
{/* Resize handles */}
|
||||||
|
|
@ -397,8 +457,18 @@ export function ImagePreview({
|
||||||
key={h.id}
|
key={h.id}
|
||||||
className="absolute z-20 size-2.5 rounded-sm border border-gray-400 bg-white shadow-sm"
|
className="absolute z-20 size-2.5 rounded-sm border border-gray-400 bg-white shadow-sm"
|
||||||
style={{
|
style={{
|
||||||
left: h.x === 0 ? -5 : h.x === 0.5 ? "calc(50% - 5px)" : "calc(100% - 5px)",
|
left:
|
||||||
top: h.y === 0 ? -5 : h.y === 0.5 ? "calc(50% - 5px)" : "calc(100% - 5px)",
|
h.x === 0
|
||||||
|
? -5
|
||||||
|
: h.x === 0.5
|
||||||
|
? "calc(50% - 5px)"
|
||||||
|
: "calc(100% - 5px)",
|
||||||
|
top:
|
||||||
|
h.y === 0
|
||||||
|
? -5
|
||||||
|
: h.y === 0.5
|
||||||
|
? "calc(50% - 5px)"
|
||||||
|
: "calc(100% - 5px)",
|
||||||
cursor: h.cursor,
|
cursor: h.cursor,
|
||||||
}}
|
}}
|
||||||
onMouseDown={(e) => handleHandleMouseDown(e, h.id)}
|
onMouseDown={(e) => handleHandleMouseDown(e, h.id)}
|
||||||
|
|
|
||||||