diff --git a/.cursor/worktrees.json b/.cursor/worktrees.json new file mode 100644 index 00000000..89a45abb --- /dev/null +++ b/.cursor/worktrees.json @@ -0,0 +1,3 @@ +{ + "setup-worktree": ["pnpm run setup"] +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..5d883bce --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,101 @@ +name: Build and Release + +on: + pull_request: + branches: + - main + workflow_dispatch: + inputs: + release_tag: + description: "Release tag (e.g. v0.14.5). Leave empty to skip release creation." + required: false + default: "" + +jobs: + build: + runs-on: macos-latest + permissions: + contents: write + strategy: + fail-fast: false + matrix: + target: + - aarch64-apple-darwin + - x86_64-apple-darwin + steps: + - uses: actions/checkout@v4 + with: + lfs: true + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + cache: true + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build for ${{ matrix.target }} + run: | + rustup target add ${{ matrix.target }} + pnpm tauri build --target ${{ matrix.target }} --config '{"bundle":{"createUpdaterArtifacts":false}}' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Zip .app bundle + run: | + cd src-tauri/target/${{ matrix.target }}/release/bundle/macos + zip -r Chorus.app.zip Chorus.app + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + name: bundle-${{ matrix.target }} + path: | + src-tauri/target/${{ matrix.target }}/release/bundle/macos/Chorus.app.zip + src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg + + release: + needs: build + if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag != '' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + - name: Rename artifacts for clarity + run: | + mkdir -p release-assets + # aarch64 (Apple Silicon) + cp artifacts/bundle-aarch64-apple-darwin/macos/Chorus.app.zip release-assets/Chorus-aarch64.app.zip + cp artifacts/bundle-aarch64-apple-darwin/dmg/*.dmg release-assets/ 2>/dev/null && \ + mv release-assets/Chorus_*.dmg release-assets/Chorus-aarch64.dmg || true + # x86_64 (Intel) + cp artifacts/bundle-x86_64-apple-darwin/macos/Chorus.app.zip release-assets/Chorus-x86_64.app.zip + cp artifacts/bundle-x86_64-apple-darwin/dmg/*.dmg release-assets/ 2>/dev/null && \ + mv release-assets/Chorus_*.dmg release-assets/Chorus-x86_64.dmg || true + ls -la release-assets/ + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.release_tag }} + name: ${{ github.event.inputs.release_tag }} + body: | + ## Chorus ${{ github.event.inputs.release_tag }} + + ### Downloads + - **Apple Silicon (M1/M2/M3):** `Chorus-aarch64.app.zip` or `Chorus-aarch64.dmg` + - **Intel:** `Chorus-x86_64.app.zip` or `Chorus-x86_64.dmg` + + > **Note:** This app is not code-signed. On first launch, right-click the app and select "Open" to bypass Gatekeeper. + files: release-assets/* + draft: false + prerelease: false diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 00000000..52ab48fa --- /dev/null +++ b/BUILD.md @@ -0,0 +1,36 @@ +# Building Chorus + +Chorus is built using Tauri, React, TypeScript, and Rust. To build the application yourself, follow these steps. + +## Prerequisites + +- [Node.js](https://nodejs.org/) (version >= 22.0.0) +- [pnpm](https://pnpm.io/) +- [Rust](https://www.rust-lang.org/) and Cargo +- [Git LFS](https://git-lfs.com/) + +## Installation + +1. Clone the repository and navigate to the directory. +2. Initialize Git LFS: + + ```bash + git lfs install --force + git lfs pull + ``` + +3. Install dependencies: + + ```bash + pnpm install + ``` + +## Building the App + +To build the production app for your platform, run: + +```bash +pnpm tauri build +``` + +This will generate the application bundle (e.g., `.app` for macOS) in `src-tauri/target/release/bundle/`. diff --git a/README.md b/README.md index e682b6ea..fe279845 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,19 @@ Chorus screenshot

+# Fork changes + +- Added profiles for favorite models in the chat window +- Dynamically fetch and select models from providers +- Prompt profiles: Choose your preferred chat persona +- Minimize model columns in multi-model chat with sidebar management and auto-minimize on empty responses +- Move model responses around in their row, changing the order they are displayed to you in +- Ability to customize default models (multi-model chat and ambient chat) +- Set app and project specific default prompt profiles + +> **Note:** This app is not code-signed. On first launch, right-click the app and select "Open" to bypass Gatekeeper. +> Alternatively, remove the quarantine flag via Terminal: `xattr -d com.apple.quarantine Chorus.app` + # Getting Started You will need: @@ -29,6 +42,10 @@ pnpm run setup # This is also our Conductor setup script pnpm run dev # This is also our Conductor run script ``` +# Building Chorus + +To build Chorus from source, please refer to the [BUILD.md](BUILD.md) file. + # Nightly Build You can download the [nightly build here](https://cdn.crabnebula.app/download/chorus/chorus/latest/platform/dmg-aarch64?channel=qa). Every push to main triggers a new build. diff --git a/package.json b/package.json index 36524bd5..bd94a3b8 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "chorus", "license": "MIT", "private": true, - "version": "0.14.5", + "version": "0.14.14", "type": "module", "scripts": { "tauri": "tauri", @@ -82,7 +82,7 @@ "@tauri-apps/plugin-process": "~2.2.1", "@tauri-apps/plugin-shell": "~2.2.1", "@tauri-apps/plugin-sql": "~2.2.0", - "@tauri-apps/plugin-store": "~2.1.0", + "@tauri-apps/plugin-store": "~2.2.0", "@tauri-apps/plugin-stronghold": "~2.2.0", "@tauri-apps/plugin-updater": "~2.7.1", "@types/jest": "^29.5.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6647e9fe..d14f23e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,109 +10,109 @@ importers: dependencies: '@anthropic-ai/sdk': specifier: ^0.33.1 - version: 0.33.1 + version: 0.33.1(encoding@0.1.13) '@dnd-kit/core': specifier: ^6.3.1 - version: 6.3.1(react-dom@18.3.1)(react@18.3.1) + version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@dnd-kit/modifiers': specifier: ^9.0.0 - version: 9.0.0(@dnd-kit/core@6.3.1)(react@18.3.1) + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@google/genai': specifier: ^0.8.0 - version: 0.8.0 + version: 0.8.0(encoding@0.1.13) '@google/generative-ai': specifier: ^0.21.0 version: 0.21.0 '@hello-pangea/dnd': specifier: ^17.0.0 - version: 17.0.0(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 17.0.0(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mendable/firecrawl-js': specifier: 1.18.2 version: 1.18.2(ws@8.18.3) '@modelcontextprotocol/sdk': specifier: ^1.10.2 - version: 1.24.3(zod@4.2.0) + version: 1.24.3(@cfworker/json-schema@4.1.1)(zod@4.2.0) '@octokit/rest': specifier: ^21.1.1 version: 21.1.1 '@posthog/ai': specifier: ^3.3.2 - version: 3.3.2(cheerio@1.1.2)(react@18.3.1)(ws@8.18.3) + version: 3.3.2(@opentelemetry/api@1.9.0)(axios@1.13.2)(cheerio@1.1.2)(encoding@0.1.13)(handlebars@4.7.8)(react@18.3.1)(ws@8.18.3) '@radix-ui/react-alert-dialog': specifier: ^1.1.11 - version: 1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-avatar': specifier: ^1.1.7 - version: 1.1.11(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-checkbox': specifier: ^1.2.3 - version: 1.3.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-collapsible': specifier: ^1.1.8 - version: 1.1.12(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-context-menu': specifier: ^2.2.12 - version: 2.2.16(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 2.2.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dialog': specifier: ^1.1.11 - version: 1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dropdown-menu': specifier: ^2.1.12 - version: 2.1.16(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-hover-card': specifier: ^1.1.11 - version: 1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-icons': specifier: ^1.3.2 version: 1.3.2(react@18.3.1) '@radix-ui/react-label': specifier: ^2.1.1 - version: 2.1.8(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-menubar': specifier: ^1.1.4 - version: 1.1.16(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-popover': specifier: ^1.1.4 - version: 1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-progress': specifier: ^1.1.1 - version: 1.1.8(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-radio-group': specifier: ^1.2.2 - version: 1.3.8(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 1.3.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-scroll-area': specifier: ^1.2.2 - version: 1.2.10(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 1.2.10(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-select': specifier: ^2.1.4 - version: 2.2.6(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-separator': specifier: ^1.1.1 - version: 1.1.8(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': specifier: ^1.2.0 version: 1.2.4(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-switch': specifier: ^1.1.2 - version: 1.2.6(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 1.2.6(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-tabs': specifier: ^1.1.2 - version: 1.1.13(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-toast': specifier: ^1.2.4 - version: 1.2.15(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-toggle': specifier: ^1.1.1 - version: 1.1.10(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 1.1.10(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-tooltip': specifier: ^1.1.6 - version: 1.2.8(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tanstack/react-query': specifier: ^5.69.0 version: 5.90.12(react@18.3.1) '@tanstack/react-query-devtools': specifier: ^5.69.0 - version: 5.91.1(@tanstack/react-query@5.90.12)(react@18.3.1) + version: 5.91.1(@tanstack/react-query@5.90.12(react@18.3.1))(react@18.3.1) '@tauri-apps/api': specifier: 2.5.0 version: 2.5.0 @@ -153,8 +153,8 @@ importers: specifier: ~2.2.0 version: 2.2.1 '@tauri-apps/plugin-store': - specifier: ~2.1.0 - version: 2.1.0 + specifier: ~2.2.0 + version: 2.2.1 '@tauri-apps/plugin-stronghold': specifier: ~2.2.0 version: 2.2.1 @@ -187,7 +187,7 @@ importers: version: 2.1.1 cmdk: specifier: 1.1.1 - version: 1.1.1(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -199,13 +199,13 @@ importers: version: 16.6.1 exa-js: specifier: ^1.6.13 - version: 1.10.2(ws@8.18.3) + version: 1.10.2(encoding@0.1.13)(ws@8.18.3) file-type: specifier: ^19.6.0 version: 19.6.0 framer-motion: specifier: ^12.9.1 - version: 12.23.26(react-dom@18.3.1)(react@18.3.1) + version: 12.23.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1) highlight.js: specifier: ^11.11.1 version: 11.11.1 @@ -268,13 +268,13 @@ importers: version: 9.1.0(@types/react@18.3.27)(react@18.3.1) react-mermaid2: specifier: ^0.1.4 - version: 0.1.4(@testing-library/dom@10.4.1)(@types/react@18.3.27)(eslint@9.39.2)(typescript@5.9.3) + version: 0.1.4(@testing-library/dom@10.4.1)(@types/react@18.3.27)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) react-resizable-panels: specifier: ^2.1.8 - version: 2.1.9(react-dom@18.3.1)(react@18.3.1) + version: 2.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-router-dom: specifier: ^6.30.0 - version: 6.30.2(react-dom@18.3.1)(react@18.3.1) + version: 6.30.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-scan: specifier: ^0.0.4 version: 0.0.4 @@ -283,10 +283,10 @@ importers: version: 15.6.6(react@18.3.1) react-window: specifier: ^1.8.11 - version: 1.8.11(react-dom@18.3.1)(react@18.3.1) + version: 1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1) reagraph: specifier: ^4.22.0 - version: 4.30.7(@types/react@18.3.27)(@types/three@0.182.0)(graphology-types@0.24.8)(immer@10.2.0)(react-dom@18.3.1)(react@18.3.1) + version: 4.30.7(@types/react@18.3.27)(@types/three@0.182.0)(graphology-types@0.24.8)(immer@10.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) rehype-highlight: specifier: ^7.0.2 version: 7.0.2 @@ -301,13 +301,13 @@ importers: version: 4.0.1 selection-popover: specifier: ^0.3.0 - version: 0.3.0(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + version: 0.3.0(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) simple-icons: specifier: ^13.21.0 version: 13.21.0 sonner: specifier: ^2.0.5 - version: 2.0.7(react-dom@18.3.1)(react@18.3.1) + version: 2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) sqlite3: specifier: ^5.1.7 version: 5.1.7 @@ -316,7 +316,7 @@ importers: version: 0.3.2 tailwindcss-animate: specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.4.19) + version: 1.0.7(tailwindcss@3.4.19(yaml@2.8.2)) tauri-plugin-macos-permissions-api: specifier: ~2.1.1 version: 2.1.1 @@ -331,7 +331,7 @@ importers: version: 2.3.3(react@18.3.1) use-react-query-auto-sync: specifier: ^0.1.0 - version: 0.1.0(@tanstack/react-query@5.90.12)(react-dom@18.3.1)(react@18.3.1) + version: 0.1.0(@tanstack/react-query@5.90.12(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) usehooks-ts: specifier: ^3.1.1 version: 3.1.1(react@18.3.1) @@ -340,26 +340,26 @@ importers: version: 9.0.1 vitest: specifier: ^2.1.9 - version: 2.1.9(@types/node@22.19.3) + version: 2.1.9(@types/node@22.19.3)(jsdom@14.1.0) zustand: specifier: ^5.0.5 - version: 5.0.9(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(use-sync-external-store@1.6.0) + version: 5.0.9(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) devDependencies: '@eslint/js': specifier: ^9.25.1 version: 9.39.2 '@tailwindcss/container-queries': specifier: ^0.1.1 - version: 0.1.1(tailwindcss@3.4.19) + version: 0.1.1(tailwindcss@3.4.19(yaml@2.8.2)) '@tailwindcss/forms': specifier: ^0.5.10 - version: 0.5.10(tailwindcss@3.4.19) + version: 0.5.10(tailwindcss@3.4.19(yaml@2.8.2)) '@tailwindcss/typography': specifier: ^0.5.16 - version: 0.5.19(tailwindcss@3.4.19) + version: 0.5.19(tailwindcss@3.4.19(yaml@2.8.2)) '@tanstack/eslint-plugin-query': specifier: ^5.74.7 - version: 5.91.2(eslint@9.39.2)(typescript@5.9.3) + version: 5.91.2(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@tauri-apps/cli': specifier: ^2.5.0 version: 2.9.6 @@ -404,7 +404,7 @@ importers: version: 9.0.8 '@vitejs/plugin-react': specifier: ^4.4.1 - version: 4.7.0(vite@5.4.21) + version: 4.7.0(vite@5.4.21(@types/node@22.19.3)) autoprefixer: specifier: ^10.4.21 version: 10.4.23(postcss@8.5.6) @@ -413,19 +413,19 @@ importers: version: 7.0.3 eslint: specifier: ^9.25.1 - version: 9.39.2 + version: 9.39.2(jiti@1.21.7) eslint-config-react-app: specifier: ^7.0.1 - version: 7.0.1(@babel/plugin-syntax-flow@7.27.1)(@babel/plugin-transform-react-jsx@7.27.1)(eslint@9.39.2)(jest@29.7.0)(typescript@5.9.3) + version: 7.0.1(@babel/plugin-syntax-flow@7.27.1(@babel/core@7.28.5))(@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.28.5))(eslint@9.39.2(jiti@1.21.7))(jest@29.7.0(@types/node@22.19.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)))(typescript@5.9.3) eslint-plugin-react: specifier: ^7.37.5 - version: 7.37.5(eslint@9.39.2) + version: 7.37.5(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-react-hooks: specifier: ^5.2.0 - version: 5.2.0(eslint@9.39.2) + version: 5.2.0(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-react-refresh: specifier: ^0.4.20 - version: 0.4.25(eslint@9.39.2) + version: 0.4.25(eslint@9.39.2(jiti@1.21.7)) fast-diff: specifier: ^1.3.0 version: 1.3.0 @@ -437,7 +437,7 @@ importers: version: 9.1.7 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.19.3)(ts-node@10.9.2) + version: 29.7.0(@types/node@22.19.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)) lint-staged: specifier: ^16.1.0 version: 16.2.7 @@ -461,10 +461,10 @@ importers: version: 2.6.0 tailwindcss: specifier: ^3.4.17 - version: 3.4.19 + version: 3.4.19(yaml@2.8.2) ts-jest: specifier: ^29.3.2 - version: 29.4.6(@babel/core@7.7.4)(jest@29.7.0)(typescript@5.9.3) + version: 29.4.6(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.19.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)))(typescript@5.9.3) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.19.3)(typescript@5.9.3) @@ -473,13 +473,13 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.31.0 - version: 8.49.0(eslint@9.39.2)(typescript@5.9.3) + version: 8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) vite: specifier: ^5.4.18 version: 5.4.21(@types/node@22.19.3) vite-plugin-node-polyfills: specifier: ^0.22.0 - version: 0.22.0(vite@5.4.21) + version: 0.22.0(rollup@4.53.4)(vite@5.4.21(@types/node@22.19.3)) packages: @@ -1933,30 +1933,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@0.1.84': resolution: {integrity: sha512-VyZq0EEw+OILnWk7G3ZgLLPaz1ERaPP++jLjeyLMbFOF+Tr4zHzWKiKDsEV/cT7btLPZbVoR3VX+T9/QubnURQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/canvas-linux-riscv64-gnu@0.1.84': resolution: {integrity: sha512-PSMTh8DiThvLRsbtc/a065I/ceZk17EXAATv9uNvHgkgo7wdEfTh2C3aveNkBMGByVO3tvnvD5v/YFtZL07cIg==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@0.1.84': resolution: {integrity: sha512-N1GY3noO1oqgEo3rYQIwY44kfM11vA0lDbN0orTOHfCSUZTUyiYCY0nZ197QMahZBm1aR/vYgsWpV74MMMDuNA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-musl@0.1.84': resolution: {integrity: sha512-vUZmua6ADqTWyHyei81aXIt9wp0yjeNwTH0KdhdeoBb6azHmFR8uKTukZMXfLCC3bnsW0t4lW7K78KNMknmtjg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/canvas-win32-x64-msvc@0.1.84': resolution: {integrity: sha512-YSs8ncurc1xzegUMNnQUTYrdrAuaXdPMOa+iYYyAxydOtg0ppV386hyYMsy00Yip1NlTgLCseRG4sHSnjQx6og==} @@ -2906,56 +2911,67 @@ packages: resolution: {integrity: sha512-UPMMNeC4LXW7ZSHxeP3Edv09aLsFUMaD1TSVW6n1CWMECnUIJMFFB7+XC2lZTdPtvB36tYC0cJWc86mzSsaviw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.53.4': resolution: {integrity: sha512-H8uwlV0otHs5Q7WAMSoyvjV9DJPiy5nJ/xnHolY0QptLPjaSsuX7tw+SPIfiYH6cnVx3fe4EWFafo6gH6ekZKA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.53.4': resolution: {integrity: sha512-BLRwSRwICXz0TXkbIbqJ1ibK+/dSBpTJqDClF61GWIrxTXZWQE78ROeIhgl5MjVs4B4gSLPCFeD4xML9vbzvCQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.53.4': resolution: {integrity: sha512-6bySEjOTbmVcPJAywjpGLckK793A0TJWSbIa0sVwtVGfe/Nz6gOWHOwkshUIAp9j7wg2WKcA4Snu7Y1nUZyQew==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.53.4': resolution: {integrity: sha512-U0ow3bXYJZ5MIbchVusxEycBw7bO6C2u5UvD31i5IMTrnt2p4Fh4ZbHSdc/31TScIJQYHwxbj05BpevB3201ug==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.53.4': resolution: {integrity: sha512-iujDk07ZNwGLVn0YIWM80SFN039bHZHCdCCuX9nyx3Jsa2d9V/0Y32F+YadzwbvDxhSeVo9zefkoPnXEImnM5w==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.53.4': resolution: {integrity: sha512-MUtAktiOUSu+AXBpx1fkuG/Bi5rhlorGs3lw5QeJ2X3ziEGAq7vFNdWVde6XGaVqi0LGSvugwjoxSNJfHFTC0g==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.53.4': resolution: {integrity: sha512-btm35eAbDfPtcFEgaXCI5l3c2WXyzwiE8pArhd66SDtoLWmgK5/M7CUxmUglkwtniPzwvWioBKKl6IXLbPf2sQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.53.4': resolution: {integrity: sha512-uJlhKE9ccUTCUlK+HUz/80cVtx2RayadC5ldDrrDUFaJK0SNb8/cCmC9RhBhIWuZ71Nqj4Uoa9+xljKWRogdhA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.53.4': resolution: {integrity: sha512-jjEMkzvASQBbzzlzf4os7nzSBd/cvPrpqXCUOqoeCh1dQ4BP3RZCJk8XBeik4MUln3m+8LeTJcY54C/u8wb3DQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.53.4': resolution: {integrity: sha512-lu90KG06NNH19shC5rBPkrh6mrTpq5kviFylPBXQVpdEu0yzb0mDgyxLr6XdcGdBIQTH/UAhDJnL+APZTBu1aQ==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.53.4': resolution: {integrity: sha512-dFDcmLwsUzhAm/dn0+dMOQZoONVYBtgik0VuY/d5IJUUb787L3Ko/ibvTvddqhb3RaB7vFEozYevHN4ox22R/w==} @@ -3122,30 +3138,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.9.6': resolution: {integrity: sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.9.6': resolution: {integrity: sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.9.6': resolution: {integrity: sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.9.6': resolution: {integrity: sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.9.6': resolution: {integrity: sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==} @@ -3206,8 +3227,8 @@ packages: '@tauri-apps/plugin-sql@2.2.1': resolution: {integrity: sha512-+pUl3uNRcIWWhU42bJVf8IUrQ2dCGyi6XUI+RZqyr0xer8BoVc1Gj7WpVvjniL73a5lpXCzs0WYdJuiPUm9gCA==} - '@tauri-apps/plugin-store@2.1.0': - resolution: {integrity: sha512-GADqrc17opUKYIAKnGHIUgEeTZ2wJGu1ZITKQ1WMuOFdv8fvXRFBAqsqPjE3opgWohbczX6e1NpwmZK1AnuWVw==} + '@tauri-apps/plugin-store@2.2.1': + resolution: {integrity: sha512-/hPafeliMe0tMc8NB9tSlkAH+Ww3H7BGD8NycjfY6QBM//0jSoqCO1QGdngPxugeF5NgUNspsVkpvpTz1lLAVQ==} '@tauri-apps/plugin-stronghold@2.2.1': resolution: {integrity: sha512-udDth65eSYFK8MUZoc78oIu3dlg3QnFky5O9/BKmX6suuq/CgzQJQK4t71yhBmO4Ch4C2DW/Lg5CgoCLDHx6Cw==} @@ -9715,7 +9736,6 @@ packages: resolution: {integrity: sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==} peerDependencies: react: ^16.14.0 - bundledDependencies: false react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} @@ -9874,7 +9894,6 @@ packages: react@16.14.0: resolution: {integrity: sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==} engines: {node: '>=0.10.0'} - bundledDependencies: false react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} @@ -10554,9 +10573,6 @@ packages: sqlite3@5.1.7: resolution: {integrity: sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==} - peerDependenciesMeta: - node-gyp: - optional: true sshpk@1.18.0: resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} @@ -11990,6 +12006,7 @@ snapshots: react: 18.3.1 swr: 2.3.7(react@18.3.1) throttleit: 2.1.0 + optionalDependencies: zod: 3.25.76 '@ai-sdk/ui-utils@1.2.11(zod@3.25.76)': @@ -12001,7 +12018,7 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@anthropic-ai/sdk@0.33.1': + '@anthropic-ai/sdk@0.33.1(encoding@0.1.13)': dependencies: '@types/node': 18.19.130 '@types/node-fetch': 2.6.13 @@ -12009,11 +12026,11 @@ snapshots: agentkeepalive: 4.6.0 form-data-encoder: 1.7.2 formdata-node: 4.4.1 - node-fetch: 2.7.0 + node-fetch: 2.7.0(encoding@0.1.13) transitivePeerDependencies: - encoding - '@anthropic-ai/sdk@0.36.3': + '@anthropic-ai/sdk@0.36.3(encoding@0.1.13)': dependencies: '@types/node': 18.19.130 '@types/node-fetch': 2.6.13 @@ -12021,7 +12038,7 @@ snapshots: agentkeepalive: 4.6.0 form-data-encoder: 1.7.2 formdata-node: 4.4.1 - node-fetch: 2.7.0 + node-fetch: 2.7.0(encoding@0.1.13) transitivePeerDependencies: - encoding @@ -12097,11 +12114,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/eslint-parser@7.28.5(@babel/core@7.28.5)(eslint@9.39.2)': + '@babel/eslint-parser@7.28.5(@babel/core@7.28.5)(eslint@9.39.2(jiti@1.21.7))': dependencies: '@babel/core': 7.28.5 '@nicolo-ribaudo/eslint-scope-5-internals': 5.1.1-v1 - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) eslint-visitor-keys: 2.1.0 semver: 6.3.1 @@ -12608,11 +12625,6 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-flow@7.27.1(@babel/core@7.7.4)': - dependencies: - '@babel/core': 7.7.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-flow@7.27.1(@babel/core@7.9.0)': dependencies: '@babel/core': 7.9.0 @@ -14150,7 +14162,7 @@ snapshots: react: 18.3.1 tslib: 2.8.1 - '@dnd-kit/core@6.3.1(react-dom@18.3.1)(react@18.3.1)': + '@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@dnd-kit/accessibility': 3.1.1(react@18.3.1) '@dnd-kit/utilities': 3.2.2(react@18.3.1) @@ -14158,9 +14170,9 @@ snapshots: react-dom: 18.3.1(react@18.3.1) tslib: 2.8.1 - '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1)(react@18.3.1)': + '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': dependencies: - '@dnd-kit/core': 6.3.1(react-dom@18.3.1)(react@18.3.1) + '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@dnd-kit/utilities': 3.2.2(react@18.3.1) react: 18.3.1 tslib: 2.8.1 @@ -14239,9 +14251,9 @@ snapshots: '@esbuild/win32-x64@0.21.5': optional: true - '@eslint-community/eslint-utils@4.9.0(eslint@9.39.2)': + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.2(jiti@1.21.7))': dependencies: - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -14294,13 +14306,13 @@ snapshots: '@floating-ui/core': 1.7.3 '@floating-ui/utils': 0.2.10 - '@floating-ui/react-dom@1.3.0(react-dom@18.3.1)(react@18.3.1)': + '@floating-ui/react-dom@1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/dom': 1.7.4 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@floating-ui/react-dom@2.1.6(react-dom@18.3.1)(react@18.3.1)': + '@floating-ui/react-dom@2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/dom': 1.7.4 react: 18.3.1 @@ -14311,9 +14323,9 @@ snapshots: '@gar/promisify@1.1.3': optional: true - '@google/genai@0.8.0': + '@google/genai@0.8.0(encoding@0.1.13)': dependencies: - google-auth-library: 9.15.1 + google-auth-library: 9.15.1(encoding@0.1.13) ws: 8.18.3 transitivePeerDependencies: - bufferutil @@ -14340,7 +14352,7 @@ snapshots: dependencies: '@hapi/hoek': 8.5.1 - '@hello-pangea/dnd@17.0.0(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@hello-pangea/dnd@17.0.0(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 css-box-model: 1.2.1 @@ -14431,7 +14443,7 @@ snapshots: - supports-color - utf-8-validate - '@jest/core@29.7.0(ts-node@10.9.2)': + '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -14445,7 +14457,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@22.19.3)(ts-node@10.9.2) + jest-config: 29.7.0(@types/node@22.19.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -14746,14 +14758,14 @@ snapshots: '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) tslib: 2.8.1 - '@langchain/core@0.3.79(openai@6.10.0)': + '@langchain/core@0.3.79(@opentelemetry/api@1.9.0)(openai@6.10.0(ws@8.18.3)(zod@4.2.0))': dependencies: '@cfworker/json-schema': 4.1.1 ansi-styles: 5.2.0 camelcase: 6.3.0 decamelize: 1.2.0 js-tiktoken: 1.0.21 - langsmith: 0.3.87(openai@6.10.0) + langsmith: 0.3.87(@opentelemetry/api@1.9.0)(openai@6.10.0(ws@8.18.3)(zod@4.2.0)) mustache: 4.2.0 p-queue: 6.6.2 p-retry: 4.6.2 @@ -14766,18 +14778,18 @@ snapshots: - '@opentelemetry/sdk-trace-base' - openai - '@langchain/openai@0.6.16(@langchain/core@0.3.79)(ws@8.18.3)': + '@langchain/openai@0.6.16(@langchain/core@0.3.79(@opentelemetry/api@1.9.0)(openai@4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76)))(ws@8.18.3)': dependencies: - '@langchain/core': 0.3.79(openai@6.10.0) + '@langchain/core': 0.3.79(@opentelemetry/api@1.9.0)(openai@6.10.0(ws@8.18.3)(zod@4.2.0)) js-tiktoken: 1.0.21 openai: 5.12.2(ws@8.18.3)(zod@3.25.76) zod: 3.25.76 transitivePeerDependencies: - ws - '@langchain/textsplitters@0.1.0(@langchain/core@0.3.79)': + '@langchain/textsplitters@0.1.0(@langchain/core@0.3.79(@opentelemetry/api@1.9.0)(openai@4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76)))': dependencies: - '@langchain/core': 0.3.79(openai@6.10.0) + '@langchain/core': 0.3.79(@opentelemetry/api@1.9.0)(openai@6.10.0(ws@8.18.3)(zod@4.2.0)) js-tiktoken: 1.0.21 '@mediapipe/tasks-vision@0.10.17': {} @@ -14795,7 +14807,7 @@ snapshots: '@mixmark-io/domino@2.2.0': {} - '@modelcontextprotocol/sdk@1.24.3(zod@4.2.0)': + '@modelcontextprotocol/sdk@1.24.3(@cfworker/json-schema@4.1.1)(zod@4.2.0)': dependencies: ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) @@ -14811,6 +14823,8 @@ snapshots: raw-body: 3.0.2 zod: 4.2.0 zod-to-json-schema: 3.25.0(zod@4.2.0) + optionalDependencies: + '@cfworker/json-schema': 4.1.1 transitivePeerDependencies: - supports-color @@ -14968,13 +14982,13 @@ snapshots: '@opentelemetry/api@1.9.0': {} - '@posthog/ai@3.3.2(cheerio@1.1.2)(react@18.3.1)(ws@8.18.3)': + '@posthog/ai@3.3.2(@opentelemetry/api@1.9.0)(axios@1.13.2)(cheerio@1.1.2)(encoding@0.1.13)(handlebars@4.7.8)(react@18.3.1)(ws@8.18.3)': dependencies: - '@anthropic-ai/sdk': 0.36.3 - '@langchain/core': 0.3.79(openai@6.10.0) + '@anthropic-ai/sdk': 0.36.3(encoding@0.1.13) + '@langchain/core': 0.3.79(@opentelemetry/api@1.9.0)(openai@6.10.0(ws@8.18.3)(zod@4.2.0)) ai: 4.3.19(react@18.3.1)(zod@3.25.76) - langchain: 0.3.36(@langchain/core@0.3.79)(cheerio@1.1.2)(openai@4.104.0)(ws@8.18.3) - openai: 4.104.0(ws@8.18.3)(zod@3.25.76) + langchain: 0.3.36(@langchain/core@0.3.79(@opentelemetry/api@1.9.0)(openai@4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76)))(@opentelemetry/api@1.9.0)(axios@1.13.2)(cheerio@1.1.2)(handlebars@4.7.8)(openai@4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76))(ws@8.18.3) + openai: 4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76) uuid: 11.1.0 zod: 3.25.76 transitivePeerDependencies: @@ -15014,86 +15028,92 @@ snapshots: '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-arrow@1.0.1(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-arrow@1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 - '@radix-ui/react-primitive': 1.0.1(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-avatar@1.1.11(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-avatar@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-context': 1.1.3(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-checkbox@1.3.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-collapsible@1.1.12(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) '@radix-ui/react-compose-refs@1.0.0(react@18.3.1)': dependencies: @@ -15103,26 +15123,29 @@ snapshots: '@radix-ui/react-compose-refs@1.0.1(@types/react@18.3.27)(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 - '@types/react': 18.3.27 react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.27)(react@18.3.1)': dependencies: - '@types/react': 18.3.27 react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 - '@radix-ui/react-context-menu@2.2.16(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) '@radix-ui/react-context@1.0.0(react@18.3.1)': dependencies: @@ -15131,109 +15154,119 @@ snapshots: '@radix-ui/react-context@1.1.2(@types/react@18.3.27)(react@18.3.1)': dependencies: - '@types/react': 18.3.27 react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 '@radix-ui/react-context@1.1.3(@types/react@18.3.27)(react@18.3.1)': dependencies: - '@types/react': 18.3.27 react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 - '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) aria-hidden: 1.2.6 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) react-remove-scroll: 2.7.2(@types/react@18.3.27)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) '@radix-ui/react-direction@1.1.1(@types/react@18.3.27)(react@18.3.1)': dependencies: - '@types/react': 18.3.27 react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 - '@radix-ui/react-dismissable-layer@1.0.4(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-dismissable-layer@1.0.4(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 '@radix-ui/primitive': 1.0.1 '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-escape-keydown': 1.0.3(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.27)(react@18.3.1)': dependencies: - '@types/react': 18.3.27 react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-hover-card@1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-hover-card@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) '@radix-ui/react-icons@1.3.2(react@18.3.1)': dependencies: @@ -15242,115 +15275,122 @@ snapshots: '@radix-ui/react-id@1.1.1(@types/react@18.3.27)(react@18.3.1)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 - '@radix-ui/react-label@2.1.8(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-label@2.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) aria-hidden: 1.2.6 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) react-remove-scroll: 2.7.2(@types/react@18.3.27)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-menubar@1.1.16(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-menubar@1.1.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) aria-hidden: 1.2.6 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) react-remove-scroll: 2.7.2(@types/react@18.3.27)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@floating-ui/react-dom': 2.1.6(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.27)(react@18.3.1) '@radix-ui/rect': 1.1.1 - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-portal@1.0.1(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-portal@1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 - '@radix-ui/react-primitive': 1.0.1(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-presence@1.0.0(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-presence@1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) @@ -15358,140 +15398,150 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-primitive@1.0.1(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-primitive@1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 '@radix-ui/react-slot': 1.0.1(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@radix-ui/react-primitive@1.0.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-primitive@1.0.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 '@radix-ui/react-slot': 1.0.2(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-slot': 1.2.4(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-progress@1.1.8(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-progress@1.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-context': 1.1.3(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-radio-group@1.3.8(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-select@2.2.6(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-select@2.2.6(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) aria-hidden: 1.2.6 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) react-remove-scroll: 2.7.2(@types/react@18.3.27)(react@18.3.1) - - '@radix-ui/react-separator@1.1.8(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + optionalDependencies: '@types/react': 18.3.27 '@types/react-dom': 18.3.7(@types/react@18.3.27) + + '@radix-ui/react-separator@1.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) '@radix-ui/react-slot@1.0.1(react@18.3.1)': dependencies: @@ -15503,97 +15553,105 @@ snapshots: dependencies: '@babel/runtime': 7.28.4 '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 '@radix-ui/react-slot@1.2.3(@types/react@18.3.27)(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 '@radix-ui/react-slot@1.2.4(@types/react@18.3.27)(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 - '@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-tabs@1.1.13(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-tabs@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-toast@1.2.15(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-toast@1.2.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-toggle@1.1.10(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-toggle@1.1.10(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) '@radix-ui/react-use-callback-ref@1.0.0(react@18.3.1)': dependencies: @@ -15603,13 +15661,15 @@ snapshots: '@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.3.27)(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 - '@types/react': 18.3.27 react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.27)(react@18.3.1)': dependencies: - '@types/react': 18.3.27 react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 '@radix-ui/react-use-controllable-state@1.0.0(react@18.3.1)': dependencies: @@ -15621,33 +15681,38 @@ snapshots: dependencies: '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.27)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.27)(react@18.3.1)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 '@radix-ui/react-use-escape-keydown@1.0.3(@types/react@18.3.27)(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.27)(react@18.3.1)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@18.3.27)(react@18.3.1)': dependencies: - '@types/react': 18.3.27 react: 18.3.1 use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 '@radix-ui/react-use-layout-effect@1.0.0(react@18.3.1)': dependencies: @@ -15656,19 +15721,22 @@ snapshots: '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.27)(react@18.3.1)': dependencies: - '@types/react': 18.3.27 react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 '@radix-ui/react-use-previous@1.1.1(@types/react@18.3.27)(react@18.3.1)': dependencies: - '@types/react': 18.3.27 react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 '@radix-ui/react-use-rect@1.1.1(@types/react@18.3.27)(react@18.3.1)': dependencies: '@radix-ui/rect': 1.1.1 - '@types/react': 18.3.27 react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 '@radix-ui/react-use-size@1.0.0(react@18.3.1)': dependencies: @@ -15679,16 +15747,18 @@ snapshots: '@radix-ui/react-use-size@1.1.1(@types/react@18.3.27)(react@18.3.1)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@types/react': 18.3.27 react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)': + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) '@radix-ui/rect@1.1.1': {} @@ -15713,24 +15783,24 @@ snapshots: '@react-spring/types': 10.0.3 react: 18.3.1 - '@react-spring/three@10.0.3(@react-three/fiber@9.3.0)(react@18.3.1)(three@0.180.0)': + '@react-spring/three@10.0.3(@react-three/fiber@9.3.0(@types/react@18.3.27)(immer@10.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.180.0))(react@18.3.1)(three@0.180.0)': dependencies: '@react-spring/animated': 10.0.3(react@18.3.1) '@react-spring/core': 10.0.3(react@18.3.1) '@react-spring/shared': 10.0.3(react@18.3.1) '@react-spring/types': 10.0.3 - '@react-three/fiber': 9.3.0(@types/react@18.3.27)(immer@10.2.0)(react-dom@18.3.1)(react@18.3.1)(three@0.180.0) + '@react-three/fiber': 9.3.0(@types/react@18.3.27)(immer@10.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.180.0) react: 18.3.1 three: 0.180.0 '@react-spring/types@10.0.3': {} - '@react-three/drei@10.7.7(@react-three/fiber@9.3.0)(@types/react@18.3.27)(@types/three@0.182.0)(immer@10.2.0)(react-dom@18.3.1)(react@18.3.1)(three@0.180.0)': + '@react-three/drei@10.7.7(@react-three/fiber@9.3.0(@types/react@18.3.27)(immer@10.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.180.0))(@types/react@18.3.27)(@types/three@0.182.0)(immer@10.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.180.0)': dependencies: '@babel/runtime': 7.28.4 '@mediapipe/tasks-vision': 0.10.17 '@monogrid/gainmap-js': 3.4.0(three@0.180.0) - '@react-three/fiber': 9.3.0(@types/react@18.3.27)(immer@10.2.0)(react-dom@18.3.1)(react@18.3.1)(three@0.180.0) + '@react-three/fiber': 9.3.0(@types/react@18.3.27)(immer@10.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.180.0) '@use-gesture/react': 10.3.1(react@18.3.1) camera-controls: 3.1.2(three@0.180.0) cross-env: 7.0.3 @@ -15740,7 +15810,6 @@ snapshots: maath: 0.10.8(@types/three@0.182.0)(three@0.180.0) meshline: 3.3.1(three@0.180.0) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) stats-gl: 2.4.2(@types/three@0.182.0)(three@0.180.0) stats.js: 0.17.0 suspend-react: 0.1.3(react@18.3.1) @@ -15751,13 +15820,15 @@ snapshots: tunnel-rat: 0.1.2(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1) use-sync-external-store: 1.6.0(react@18.3.1) utility-types: 3.11.0 - zustand: 5.0.9(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(use-sync-external-store@1.6.0) + zustand: 5.0.9(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) transitivePeerDependencies: - '@types/react' - '@types/three' - immer - '@react-three/fiber@9.3.0(@types/react@18.3.27)(immer@10.2.0)(react-dom@18.3.1)(react@18.3.1)(three@0.180.0)': + '@react-three/fiber@9.3.0(@types/react@18.3.27)(immer@10.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.180.0)': dependencies: '@babel/runtime': 7.28.4 '@types/react-reconciler': 0.32.3(@types/react@18.3.27) @@ -15766,14 +15837,15 @@ snapshots: buffer: 6.0.3 its-fine: 2.0.0(@types/react@18.3.27)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) react-reconciler: 0.31.0(react@18.3.1) - react-use-measure: 2.1.7(react-dom@18.3.1)(react@18.3.1) + react-use-measure: 2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) scheduler: 0.25.0 suspend-react: 0.1.3(react@18.3.1) three: 0.180.0 use-sync-external-store: 1.6.0(react@18.3.1) - zustand: 5.0.9(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(use-sync-external-store@1.6.0) + zustand: 5.0.9(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) transitivePeerDependencies: - '@types/react' - immer @@ -15782,17 +15854,21 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.27': {} - '@rollup/plugin-inject@5.0.5': + '@rollup/plugin-inject@5.0.5(rollup@4.53.4)': dependencies: - '@rollup/pluginutils': 5.3.0 + '@rollup/pluginutils': 5.3.0(rollup@4.53.4) estree-walker: 2.0.2 magic-string: 0.30.21 + optionalDependencies: + rollup: 4.53.4 - '@rollup/pluginutils@5.3.0': + '@rollup/pluginutils@5.3.0(rollup@4.53.4)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.3 + optionalDependencies: + rollup: 4.53.4 '@rollup/rollup-android-arm-eabi@4.53.4': optional: true @@ -15945,24 +16021,24 @@ snapshots: transitivePeerDependencies: - supports-color - '@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.19)': + '@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.19(yaml@2.8.2))': dependencies: - tailwindcss: 3.4.19 + tailwindcss: 3.4.19(yaml@2.8.2) - '@tailwindcss/forms@0.5.10(tailwindcss@3.4.19)': + '@tailwindcss/forms@0.5.10(tailwindcss@3.4.19(yaml@2.8.2))': dependencies: mini-svg-data-uri: 1.4.4 - tailwindcss: 3.4.19 + tailwindcss: 3.4.19(yaml@2.8.2) - '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19)': + '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(yaml@2.8.2))': dependencies: postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.19 + tailwindcss: 3.4.19(yaml@2.8.2) - '@tanstack/eslint-plugin-query@5.91.2(eslint@9.39.2)(typescript@5.9.3)': + '@tanstack/eslint-plugin-query@5.91.2(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/utils': 8.49.0(eslint@9.39.2)(typescript@5.9.3) - eslint: 9.39.2 + '@typescript-eslint/utils': 8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) transitivePeerDependencies: - supports-color - typescript @@ -15971,7 +16047,7 @@ snapshots: '@tanstack/query-devtools@5.91.1': {} - '@tanstack/react-query-devtools@5.91.1(@tanstack/react-query@5.90.12)(react@18.3.1)': + '@tanstack/react-query-devtools@5.91.1(@tanstack/react-query@5.90.12(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/query-devtools': 5.91.1 '@tanstack/react-query': 5.90.12(react@18.3.1) @@ -16079,7 +16155,7 @@ snapshots: dependencies: '@tauri-apps/api': 2.5.0 - '@tauri-apps/plugin-store@2.1.0': + '@tauri-apps/plugin-store@2.2.1': dependencies: '@tauri-apps/api': 2.5.0 @@ -16124,7 +16200,7 @@ snapshots: pretty-format: 24.9.0 redent: 3.0.0 - '@testing-library/react@9.5.0(@types/react@18.3.27)(react-dom@16.14.0)(react@16.14.0)': + '@testing-library/react@9.5.0(@types/react@18.3.27)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)': dependencies: '@babel/runtime': 7.28.4 '@testing-library/dom': 6.16.0 @@ -16375,45 +16451,47 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@2.34.0(@typescript-eslint/parser@2.34.0)(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@2.34.0(@typescript-eslint/parser@2.34.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/experimental-utils': 2.34.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/parser': 2.34.0(eslint@9.39.2)(typescript@5.9.3) - eslint: 9.39.2 + '@typescript-eslint/experimental-utils': 2.34.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 2.34.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) functional-red-black-tree: 1.0.1 regexpp: 3.2.0 tsutils: 3.21.0(typescript@5.9.3) + optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0)(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 5.62.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/parser': 5.62.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/scope-manager': 5.62.0 - '@typescript-eslint/type-utils': 5.62.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/utils': 5.62.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/type-utils': 5.62.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 5.62.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) debug: 4.4.3(supports-color@6.1.0) - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) graphemer: 1.4.0 ignore: 5.3.2 natural-compare-lite: 1.4.0 semver: 7.7.3 tsutils: 3.21.0(typescript@5.9.3) + optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.49.0(@typescript-eslint/parser@8.49.0)(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.49.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/parser': 8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.49.0 - '@typescript-eslint/type-utils': 8.49.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/utils': 8.49.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.49.0 - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.1.0(typescript@5.9.3) @@ -16421,55 +16499,57 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/experimental-utils@2.34.0(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/experimental-utils@2.34.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@types/json-schema': 7.0.15 '@typescript-eslint/typescript-estree': 2.34.0(typescript@5.9.3) - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) eslint-scope: 5.1.1 eslint-utils: 2.1.0 transitivePeerDependencies: - supports-color - typescript - '@typescript-eslint/experimental-utils@5.62.0(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/experimental-utils@5.62.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/utils': 5.62.0(eslint@9.39.2)(typescript@5.9.3) - eslint: 9.39.2 + '@typescript-eslint/utils': 5.62.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) transitivePeerDependencies: - supports-color - typescript - '@typescript-eslint/parser@2.34.0(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/parser@2.34.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@types/eslint-visitor-keys': 1.0.0 - '@typescript-eslint/experimental-utils': 2.34.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/experimental-utils': 2.34.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 2.34.0(typescript@5.9.3) - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) eslint-visitor-keys: 1.3.0 + optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@5.62.0(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/types': 5.62.0 '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.9.3) debug: 4.4.3(supports-color@6.1.0) - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) + optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.49.0(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.49.0 '@typescript-eslint/types': 8.49.0 '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.49.0 debug: 4.4.3(supports-color@6.1.0) - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -16497,24 +16577,25 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@5.62.0(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/type-utils@5.62.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.9.3) - '@typescript-eslint/utils': 5.62.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/utils': 5.62.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) debug: 4.4.3(supports-color@6.1.0) - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) tsutils: 3.21.0(typescript@5.9.3) + optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.49.0(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.49.0 '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.49.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) debug: 4.4.3(supports-color@6.1.0) - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) ts-api-utils: 2.1.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -16533,6 +16614,7 @@ snapshots: lodash: 4.17.21 semver: 7.7.3 tsutils: 3.21.0(typescript@5.9.3) + optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -16546,6 +16628,7 @@ snapshots: is-glob: 4.0.3 semver: 7.7.3 tsutils: 3.21.0(typescript@5.9.3) + optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -16565,28 +16648,28 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@5.62.0(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/utils@5.62.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@1.21.7)) '@types/json-schema': 7.0.15 '@types/semver': 7.7.1 '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/types': 5.62.0 '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.9.3) - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) eslint-scope: 5.1.1 semver: 7.7.3 transitivePeerDependencies: - supports-color - typescript - '@typescript-eslint/utils@8.49.0(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/utils@8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@1.21.7)) '@typescript-eslint/scope-manager': 8.49.0 '@typescript-eslint/types': 8.49.0 '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -16610,7 +16693,7 @@ snapshots: '@use-gesture/core': 10.3.1 react: 18.3.1 - '@vitejs/plugin-react@4.7.0(vite@5.4.21)': + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.19.3))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -16629,11 +16712,12 @@ snapshots: chai: 5.3.3 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.9(vite@5.4.21)': + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.3))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 + optionalDependencies: vite: 5.4.21(@types/node@22.19.3) '@vitest/pretty-format@2.1.9': @@ -16837,15 +16921,16 @@ snapshots: '@ai-sdk/ui-utils': 1.2.11(zod@3.25.76) '@opentelemetry/api': 1.9.0 jsondiffpatch: 0.6.0 - react: 18.3.1 zod: 3.25.76 + optionalDependencies: + react: 18.3.1 ajv-errors@1.0.1(ajv@6.12.6): dependencies: ajv: 6.12.6 ajv-formats@3.0.1(ajv@8.17.1): - dependencies: + optionalDependencies: ajv: 8.17.1 ajv-keywords@3.5.2(ajv@6.12.6): @@ -17149,7 +17234,7 @@ snapshots: axios@1.13.2: dependencies: - follow-redirects: 1.15.11(debug@4.4.3) + follow-redirects: 1.15.11(debug@4.4.3(supports-color@6.1.0)) form-data: 4.0.5 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -17165,13 +17250,13 @@ snapshots: esutils: 2.0.3 js-tokens: 3.0.2 - babel-eslint@10.0.3(eslint@9.39.2): + babel-eslint@10.0.3(eslint@9.39.2(jiti@1.21.7)): dependencies: '@babel/code-frame': 7.27.1 '@babel/parser': 7.28.5 '@babel/traverse': 7.28.5 '@babel/types': 7.28.5 - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) eslint-visitor-keys: 1.3.0 resolve: 1.12.2 transitivePeerDependencies: @@ -17969,12 +18054,12 @@ snapshots: clsx@2.1.1: {} - cmdk@1.1.1(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1): + cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) transitivePeerDependencies: @@ -18198,13 +18283,13 @@ snapshots: safe-buffer: 5.2.1 sha.js: 2.4.12 - create-jest@29.7.0(@types/node@22.19.3)(ts-node@10.9.2): + create-jest@29.7.0(@types/node@22.19.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@22.19.3)(ts-node@10.9.2) + jest-config: 29.7.0(@types/node@22.19.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -18219,9 +18304,9 @@ snapshots: dependencies: cross-spawn: 7.0.6 - cross-fetch@4.1.0: + cross-fetch@4.1.0(encoding@0.1.13): dependencies: - node-fetch: 2.7.0 + node-fetch: 2.7.0(encoding@0.1.13) transitivePeerDependencies: - encoding @@ -18786,16 +18871,19 @@ snapshots: debug@2.6.9(supports-color@6.1.0): dependencies: ms: 2.0.0 + optionalDependencies: supports-color: 6.1.0 debug@3.2.7(supports-color@6.1.0): dependencies: ms: 2.1.3 + optionalDependencies: supports-color: 6.1.0 debug@4.4.3(supports-color@6.1.0): dependencies: ms: 2.1.3 + optionalDependencies: supports-color: 6.1.0 decamelize@1.2.0: {} @@ -18810,7 +18898,9 @@ snapshots: dependencies: mimic-response: 3.1.0 - dedent@1.7.0: {} + dedent@1.7.0(babel-plugin-macros@3.1.0): + optionalDependencies: + babel-plugin-macros: 3.1.0 deep-eql@5.0.2: {} @@ -19313,37 +19403,39 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-react-app@5.2.1(@typescript-eslint/eslint-plugin@2.34.0)(@typescript-eslint/parser@2.34.0)(babel-eslint@10.0.3)(eslint-plugin-flowtype@3.13.0)(eslint-plugin-import@2.18.2)(eslint-plugin-jsx-a11y@6.2.3)(eslint-plugin-react-hooks@1.7.0)(eslint-plugin-react@7.16.0)(eslint@9.39.2)(typescript@5.9.3): + eslint-config-react-app@5.2.1(@typescript-eslint/eslint-plugin@2.34.0(@typescript-eslint/parser@2.34.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/parser@2.34.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(babel-eslint@10.0.3(eslint@9.39.2(jiti@1.21.7)))(eslint-plugin-flowtype@3.13.0(eslint@9.39.2(jiti@1.21.7)))(eslint-plugin-import@2.18.2(@typescript-eslint/parser@2.34.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)))(eslint-plugin-jsx-a11y@6.2.3(eslint@9.39.2(jiti@1.21.7)))(eslint-plugin-react-hooks@1.7.0(eslint@9.39.2(jiti@1.21.7)))(eslint-plugin-react@7.16.0(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 2.34.0(@typescript-eslint/parser@2.34.0)(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/parser': 2.34.0(eslint@9.39.2)(typescript@5.9.3) - babel-eslint: 10.0.3(eslint@9.39.2) + '@typescript-eslint/eslint-plugin': 2.34.0(@typescript-eslint/parser@2.34.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 2.34.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + babel-eslint: 10.0.3(eslint@9.39.2(jiti@1.21.7)) confusing-browser-globals: 1.0.11 - eslint: 9.39.2 - eslint-plugin-flowtype: 3.13.0(eslint@9.39.2) - eslint-plugin-import: 2.18.2(@typescript-eslint/parser@2.34.0)(eslint@9.39.2) - eslint-plugin-jsx-a11y: 6.2.3(eslint@9.39.2) - eslint-plugin-react: 7.16.0(eslint@9.39.2) - eslint-plugin-react-hooks: 1.7.0(eslint@9.39.2) + eslint: 9.39.2(jiti@1.21.7) + eslint-plugin-flowtype: 3.13.0(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-import: 2.18.2(@typescript-eslint/parser@2.34.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-jsx-a11y: 6.2.3(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-react: 7.16.0(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-react-hooks: 1.7.0(eslint@9.39.2(jiti@1.21.7)) + optionalDependencies: typescript: 5.9.3 - eslint-config-react-app@7.0.1(@babel/plugin-syntax-flow@7.27.1)(@babel/plugin-transform-react-jsx@7.27.1)(eslint@9.39.2)(jest@29.7.0)(typescript@5.9.3): + eslint-config-react-app@7.0.1(@babel/plugin-syntax-flow@7.27.1(@babel/core@7.28.5))(@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.28.5))(eslint@9.39.2(jiti@1.21.7))(jest@29.7.0(@types/node@22.19.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)))(typescript@5.9.3): dependencies: '@babel/core': 7.28.5 - '@babel/eslint-parser': 7.28.5(@babel/core@7.28.5)(eslint@9.39.2) + '@babel/eslint-parser': 7.28.5(@babel/core@7.28.5)(eslint@9.39.2(jiti@1.21.7)) '@rushstack/eslint-patch': 1.15.0 - '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0)(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/parser': 5.62.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 5.62.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) babel-preset-react-app: 10.1.0 confusing-browser-globals: 1.0.11 - eslint: 9.39.2 - eslint-plugin-flowtype: 8.0.3(@babel/plugin-syntax-flow@7.27.1)(@babel/plugin-transform-react-jsx@7.27.1)(eslint@9.39.2) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0)(eslint@9.39.2) - eslint-plugin-jest: 25.7.0(@typescript-eslint/eslint-plugin@5.62.0)(eslint@9.39.2)(jest@29.7.0)(typescript@5.9.3) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2) - eslint-plugin-react: 7.37.5(eslint@9.39.2) - eslint-plugin-react-hooks: 4.6.2(eslint@9.39.2) - eslint-plugin-testing-library: 5.11.1(eslint@9.39.2)(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) + eslint-plugin-flowtype: 8.0.3(@babel/plugin-syntax-flow@7.27.1(@babel/core@7.28.5))(@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.28.5))(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-jest: 25.7.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(jest@29.7.0(@types/node@22.19.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)))(typescript@5.9.3) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-react-hooks: 4.6.2(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-testing-library: 5.11.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - '@babel/plugin-syntax-flow' @@ -19361,9 +19453,9 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-loader@3.0.2(eslint@9.39.2)(webpack@4.41.2): + eslint-loader@3.0.2(eslint@9.39.2(jiti@1.21.7))(webpack@4.41.2): dependencies: - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) fs-extra: 8.1.0 loader-fs-cache: 1.0.3 loader-utils: 1.4.2 @@ -19371,70 +19463,72 @@ snapshots: schema-utils: 2.7.1 webpack: 4.41.2 - eslint-module-utils@2.12.1(@typescript-eslint/parser@2.34.0)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2): + eslint-module-utils@2.12.1(@typescript-eslint/parser@2.34.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@1.21.7)): dependencies: - '@typescript-eslint/parser': 2.34.0(eslint@9.39.2)(typescript@5.9.3) debug: 3.2.7(supports-color@6.1.0) - eslint: 9.39.2 + optionalDependencies: + '@typescript-eslint/parser': 2.34.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2): + eslint-module-utils@2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@1.21.7)): dependencies: - '@typescript-eslint/parser': 5.62.0(eslint@9.39.2)(typescript@5.9.3) debug: 3.2.7(supports-color@6.1.0) - eslint: 9.39.2 + optionalDependencies: + '@typescript-eslint/parser': 5.62.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-flowtype@3.13.0(eslint@9.39.2): + eslint-plugin-flowtype@3.13.0(eslint@9.39.2(jiti@1.21.7)): dependencies: - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) lodash: 4.17.21 - eslint-plugin-flowtype@8.0.3(@babel/plugin-syntax-flow@7.27.1)(@babel/plugin-transform-react-jsx@7.27.1)(eslint@9.39.2): + eslint-plugin-flowtype@8.0.3(@babel/plugin-syntax-flow@7.27.1(@babel/core@7.28.5))(@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.28.5))(eslint@9.39.2(jiti@1.21.7)): dependencies: - '@babel/plugin-syntax-flow': 7.27.1(@babel/core@7.7.4) - '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.7.4) - eslint: 9.39.2 + '@babel/plugin-syntax-flow': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.28.5) + eslint: 9.39.2(jiti@1.21.7) lodash: 4.17.21 string-natural-compare: 3.0.1 - eslint-plugin-import@2.18.2(@typescript-eslint/parser@2.34.0)(eslint@9.39.2): + eslint-plugin-import@2.18.2(@typescript-eslint/parser@2.34.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)): dependencies: - '@typescript-eslint/parser': 2.34.0(eslint@9.39.2)(typescript@5.9.3) array-includes: 3.1.9 contains-path: 0.1.0 debug: 2.6.9(supports-color@6.1.0) doctrine: 1.5.0 - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@2.34.0)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@2.34.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@1.21.7)) has: 1.0.4 minimatch: 3.1.2 object.values: 1.2.1 read-pkg-up: 2.0.0 resolve: 1.12.2 + optionalDependencies: + '@typescript-eslint/parser': 2.34.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0)(eslint@9.39.2): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)): dependencies: '@rtsao/scc': 1.1.0 - '@typescript-eslint/parser': 5.62.0(eslint@9.39.2)(typescript@5.9.3) array-includes: 3.1.9 array.prototype.findlastindex: 1.2.6 array.prototype.flat: 1.3.3 array.prototype.flatmap: 1.3.3 debug: 3.2.7(supports-color@6.1.0) doctrine: 2.1.0 - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@1.21.7)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -19445,22 +19539,25 @@ snapshots: semver: 6.3.1 string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 5.62.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jest@25.7.0(@typescript-eslint/eslint-plugin@5.62.0)(eslint@9.39.2)(jest@29.7.0)(typescript@5.9.3): + eslint-plugin-jest@25.7.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(jest@29.7.0(@types/node@22.19.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0)(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/experimental-utils': 5.62.0(eslint@9.39.2)(typescript@5.9.3) - eslint: 9.39.2 - jest: 29.7.0(@types/node@22.19.3)(ts-node@10.9.2) + '@typescript-eslint/experimental-utils': 5.62.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) + optionalDependencies: + '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + jest: 29.7.0(@types/node@22.19.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)) transitivePeerDependencies: - supports-color - typescript - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@1.21.7)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -19470,7 +19567,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -19479,7 +19576,7 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-jsx-a11y@6.2.3(eslint@9.39.2): + eslint-plugin-jsx-a11y@6.2.3(eslint@9.39.2(jiti@1.21.7)): dependencies: '@babel/runtime': 7.28.4 aria-query: 3.0.0 @@ -19488,31 +19585,31 @@ snapshots: axobject-query: 2.2.0 damerau-levenshtein: 1.0.8 emoji-regex: 7.0.3 - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) has: 1.0.4 jsx-ast-utils: 2.4.1 - eslint-plugin-react-hooks@1.7.0(eslint@9.39.2): + eslint-plugin-react-hooks@1.7.0(eslint@9.39.2(jiti@1.21.7)): dependencies: - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) - eslint-plugin-react-hooks@4.6.2(eslint@9.39.2): + eslint-plugin-react-hooks@4.6.2(eslint@9.39.2(jiti@1.21.7)): dependencies: - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) - eslint-plugin-react-hooks@5.2.0(eslint@9.39.2): + eslint-plugin-react-hooks@5.2.0(eslint@9.39.2(jiti@1.21.7)): dependencies: - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) - eslint-plugin-react-refresh@0.4.25(eslint@9.39.2): + eslint-plugin-react-refresh@0.4.25(eslint@9.39.2(jiti@1.21.7)): dependencies: - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) - eslint-plugin-react@7.16.0(eslint@9.39.2): + eslint-plugin-react@7.16.0(eslint@9.39.2(jiti@1.21.7)): dependencies: array-includes: 3.1.9 doctrine: 2.1.0 - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) has: 1.0.4 jsx-ast-utils: 2.4.1 object.entries: 1.1.9 @@ -19521,7 +19618,7 @@ snapshots: prop-types: 15.8.1 resolve: 1.12.2 - eslint-plugin-react@7.37.5(eslint@9.39.2): + eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@1.21.7)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -19529,7 +19626,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.2.2 - eslint: 9.39.2 + eslint: 9.39.2(jiti@1.21.7) estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 @@ -19543,10 +19640,10 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-testing-library@5.11.1(eslint@9.39.2)(typescript@5.9.3): + eslint-plugin-testing-library@5.11.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): dependencies: - '@typescript-eslint/utils': 5.62.0(eslint@9.39.2)(typescript@5.9.3) - eslint: 9.39.2 + '@typescript-eslint/utils': 5.62.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) transitivePeerDependencies: - supports-color - typescript @@ -19578,9 +19675,9 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.39.2: + eslint@9.39.2(jiti@1.21.7): dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@1.21.7)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 @@ -19614,6 +19711,8 @@ snapshots: minimatch: 3.1.2 natural-compare: 1.4.0 optionator: 0.9.4 + optionalDependencies: + jiti: 1.21.7 transitivePeerDependencies: - supports-color @@ -19682,9 +19781,9 @@ snapshots: md5.js: 1.3.5 safe-buffer: 5.2.1 - exa-js@1.10.2(ws@8.18.3): + exa-js@1.10.2(encoding@0.1.13)(ws@8.18.3): dependencies: - cross-fetch: 4.1.0 + cross-fetch: 4.1.0(encoding@0.1.13) dotenv: 16.4.7 openai: 5.23.2(ws@8.18.3)(zod@3.25.76) zod: 3.25.76 @@ -19919,7 +20018,7 @@ snapshots: bser: 2.1.1 fdir@6.5.0(picomatch@4.0.3): - dependencies: + optionalDependencies: picomatch: 4.0.3 fflate@0.4.8: {} @@ -20044,8 +20143,8 @@ snapshots: inherits: 2.0.4 readable-stream: 2.3.8 - follow-redirects@1.15.11(debug@4.4.3): - dependencies: + follow-redirects@1.15.11(debug@4.4.3(supports-color@6.1.0)): + optionalDependencies: debug: 4.4.3(supports-color@6.1.0) for-each@0.3.5: @@ -20062,12 +20161,11 @@ snapshots: forever-agent@0.6.1: {} - fork-ts-checker-webpack-plugin@3.1.1(eslint@9.39.2)(typescript@5.9.3)(webpack@4.41.2): + fork-ts-checker-webpack-plugin@3.1.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)(webpack@4.41.2): dependencies: babel-code-frame: 6.26.0 chalk: 2.4.2 chokidar: 3.6.0 - eslint: 9.39.2 micromatch: 3.1.10(supports-color@6.1.0) minimatch: 3.1.2 semver: 5.7.2 @@ -20075,6 +20173,8 @@ snapshots: typescript: 5.9.3 webpack: 4.41.2 worker-rpc: 0.1.1 + optionalDependencies: + eslint: 9.39.2(jiti@1.21.7) transitivePeerDependencies: - supports-color @@ -20109,13 +20209,14 @@ snapshots: dependencies: map-cache: 0.2.2 - framer-motion@12.23.26(react-dom@18.3.1)(react@18.3.1): + framer-motion@12.23.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: motion-dom: 12.23.23 motion-utils: 12.23.6 + tslib: 2.8.1 + optionalDependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - tslib: 2.8.1 fresh@0.5.2: {} @@ -20198,20 +20299,20 @@ snapshots: wide-align: 1.1.5 optional: true - gaxios@6.7.1: + gaxios@6.7.1(encoding@0.1.13): dependencies: extend: 3.0.2 https-proxy-agent: 7.0.6 is-stream: 2.0.1 - node-fetch: 2.7.0 + node-fetch: 2.7.0(encoding@0.1.13) uuid: 9.0.1 transitivePeerDependencies: - encoding - supports-color - gcp-metadata@6.1.1: + gcp-metadata@6.1.1(encoding@0.1.13): dependencies: - gaxios: 6.7.1 + gaxios: 6.7.1(encoding@0.1.13) google-logging-utils: 0.0.2 json-bigint: 1.0.0 transitivePeerDependencies: @@ -20355,13 +20456,13 @@ snapshots: glsl-noise@0.0.0: {} - google-auth-library@9.15.1: + google-auth-library@9.15.1(encoding@0.1.13): dependencies: base64-js: 1.5.1 ecdsa-sig-formatter: 1.0.11 - gaxios: 6.7.1 - gcp-metadata: 6.1.1 - gtoken: 7.1.0 + gaxios: 6.7.1(encoding@0.1.13) + gcp-metadata: 6.1.1(encoding@0.1.13) + gtoken: 7.1.0(encoding@0.1.13) jws: 4.0.1 transitivePeerDependencies: - encoding @@ -20431,9 +20532,9 @@ snapshots: growly@1.3.0: {} - gtoken@7.1.0: + gtoken@7.1.0(encoding@0.1.13): dependencies: - gaxios: 6.7.1 + gaxios: 6.7.1(encoding@0.1.13) jws: 4.0.1 transitivePeerDependencies: - encoding @@ -20749,9 +20850,9 @@ snapshots: - supports-color optional: true - http-proxy-middleware@0.19.1(debug@4.4.3)(supports-color@6.1.0): + http-proxy-middleware@0.19.1(debug@4.4.3(supports-color@6.1.0))(supports-color@6.1.0): dependencies: - http-proxy: 1.18.1(debug@4.4.3) + http-proxy: 1.18.1(debug@4.4.3(supports-color@6.1.0)) is-glob: 4.0.3 lodash: 4.17.21 micromatch: 3.1.10(supports-color@6.1.0) @@ -20759,10 +20860,10 @@ snapshots: - debug - supports-color - http-proxy@1.18.1(debug@4.4.3): + http-proxy@1.18.1(debug@4.4.3(supports-color@6.1.0)): dependencies: eventemitter3: 4.0.7 - follow-redirects: 1.15.11(debug@4.4.3) + follow-redirects: 1.15.11(debug@4.4.3(supports-color@6.1.0)) requires-port: 1.0.0 transitivePeerDependencies: - debug @@ -21326,7 +21427,7 @@ snapshots: jest-util: 29.7.0 p-limit: 3.1.0 - jest-circus@29.7.0: + jest-circus@29.7.0(babel-plugin-macros@3.1.0): dependencies: '@jest/environment': 29.7.0 '@jest/expect': 29.7.0 @@ -21335,7 +21436,7 @@ snapshots: '@types/node': 22.19.3 chalk: 4.1.2 co: 4.6.0 - dedent: 1.7.0 + dedent: 1.7.0(babel-plugin-macros@3.1.0) is-generator-fn: 2.1.0 jest-each: 29.7.0 jest-matcher-utils: 29.7.0 @@ -21372,16 +21473,16 @@ snapshots: - supports-color - utf-8-validate - jest-cli@29.7.0(@types/node@22.19.3)(ts-node@10.9.2): + jest-cli@29.7.0(@types/node@22.19.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@22.19.3)(ts-node@10.9.2) + create-jest: 29.7.0(@types/node@22.19.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@22.19.3)(ts-node@10.9.2) + jest-config: 29.7.0(@types/node@22.19.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -21415,19 +21516,18 @@ snapshots: - supports-color - utf-8-validate - jest-config@29.7.0(@types/node@22.19.3)(ts-node@10.9.2): + jest-config@29.7.0(@types/node@22.19.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)): dependencies: '@babel/core': 7.28.5 '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 22.19.3 babel-jest: 29.7.0(@babel/core@7.28.5) chalk: 4.1.2 ci-info: 3.9.0 deepmerge: 4.3.1 glob: 7.2.3 graceful-fs: 4.2.11 - jest-circus: 29.7.0 + jest-circus: 29.7.0(babel-plugin-macros@3.1.0) jest-environment-node: 29.7.0 jest-get-type: 29.6.3 jest-regex-util: 29.6.3 @@ -21440,6 +21540,8 @@ snapshots: pretty-format: 29.7.0 slash: 3.0.0 strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.19.3 ts-node: 10.9.2(@types/node@22.19.3)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros @@ -21584,7 +21686,9 @@ snapshots: pretty-format: 24.9.0 throat: 4.1.0 transitivePeerDependencies: + - bufferutil - supports-color + - utf-8-validate jest-leak-detector@24.9.0: dependencies: @@ -21646,11 +21750,11 @@ snapshots: jest-util: 29.7.0 jest-pnp-resolver@1.2.3(jest-resolve@24.9.0): - dependencies: + optionalDependencies: jest-resolve: 24.9.0 jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): - dependencies: + optionalDependencies: jest-resolve: 29.7.0 jest-regex-util@24.9.0: {} @@ -21946,12 +22050,12 @@ snapshots: - supports-color - utf-8-validate - jest@29.7.0(@types/node@22.19.3)(ts-node@10.9.2): + jest@29.7.0(@types/node@22.19.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@22.19.3)(ts-node@10.9.2) + jest-cli: 29.7.0(@types/node@22.19.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -22162,21 +22266,24 @@ snapshots: kleur@3.0.3: {} - langchain@0.3.36(@langchain/core@0.3.79)(cheerio@1.1.2)(openai@4.104.0)(ws@8.18.3): + langchain@0.3.36(@langchain/core@0.3.79(@opentelemetry/api@1.9.0)(openai@4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76)))(@opentelemetry/api@1.9.0)(axios@1.13.2)(cheerio@1.1.2)(handlebars@4.7.8)(openai@4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76))(ws@8.18.3): dependencies: - '@langchain/core': 0.3.79(openai@6.10.0) - '@langchain/openai': 0.6.16(@langchain/core@0.3.79)(ws@8.18.3) - '@langchain/textsplitters': 0.1.0(@langchain/core@0.3.79) - cheerio: 1.1.2 + '@langchain/core': 0.3.79(@opentelemetry/api@1.9.0)(openai@6.10.0(ws@8.18.3)(zod@4.2.0)) + '@langchain/openai': 0.6.16(@langchain/core@0.3.79(@opentelemetry/api@1.9.0)(openai@4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76)))(ws@8.18.3) + '@langchain/textsplitters': 0.1.0(@langchain/core@0.3.79(@opentelemetry/api@1.9.0)(openai@4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76))) js-tiktoken: 1.0.21 js-yaml: 4.1.1 jsonpointer: 5.0.1 - langsmith: 0.3.87(openai@4.104.0) + langsmith: 0.3.87(@opentelemetry/api@1.9.0)(openai@4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76)) openapi-types: 12.1.3 p-retry: 4.6.2 uuid: 10.0.0 yaml: 2.8.2 zod: 3.25.76 + optionalDependencies: + axios: 1.13.2 + cheerio: 1.1.2 + handlebars: 4.7.8 transitivePeerDependencies: - '@opentelemetry/api' - '@opentelemetry/exporter-trace-otlp-proto' @@ -22184,25 +22291,29 @@ snapshots: - openai - ws - langsmith@0.3.87(openai@4.104.0): + langsmith@0.3.87(@opentelemetry/api@1.9.0)(openai@4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76)): dependencies: '@types/uuid': 10.0.0 chalk: 4.1.2 console-table-printer: 2.15.0 - openai: 4.104.0(ws@8.18.3)(zod@3.25.76) p-queue: 6.6.2 semver: 7.7.3 uuid: 10.0.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + openai: 4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76) - langsmith@0.3.87(openai@6.10.0): + langsmith@0.3.87(@opentelemetry/api@1.9.0)(openai@6.10.0(ws@8.18.3)(zod@4.2.0)): dependencies: '@types/uuid': 10.0.0 chalk: 4.1.2 console-table-printer: 2.15.0 - openai: 6.10.0(ws@8.18.3)(zod@4.2.0) p-queue: 6.6.2 semver: 7.7.3 uuid: 10.0.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + openai: 6.10.0(ws@8.18.3)(zod@4.2.0) language-subtag-registry@0.3.23: {} @@ -23136,9 +23247,11 @@ snapshots: node-domexception@1.0.0: {} - node-fetch@2.7.0: + node-fetch@2.7.0(encoding@0.1.13): dependencies: whatwg-url: 5.0.0 + optionalDependencies: + encoding: 0.1.13 node-forge@0.10.0: {} @@ -23394,7 +23507,7 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@4.104.0(ws@8.18.3)(zod@3.25.76): + openai@4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76): dependencies: '@types/node': 18.19.130 '@types/node-fetch': 2.6.13 @@ -23402,24 +23515,25 @@ snapshots: agentkeepalive: 4.6.0 form-data-encoder: 1.7.2 formdata-node: 4.4.1 - node-fetch: 2.7.0 + node-fetch: 2.7.0(encoding@0.1.13) + optionalDependencies: ws: 8.18.3 zod: 3.25.76 transitivePeerDependencies: - encoding openai@5.12.2(ws@8.18.3)(zod@3.25.76): - dependencies: + optionalDependencies: ws: 8.18.3 zod: 3.25.76 openai@5.23.2(ws@8.18.3)(zod@3.25.76): - dependencies: + optionalDependencies: ws: 8.18.3 zod: 3.25.76 openai@6.10.0(ws@8.18.3)(zod@4.2.0): - dependencies: + optionalDependencies: ws: 8.18.3 zod: 4.2.0 @@ -23903,11 +24017,13 @@ snapshots: cosmiconfig: 5.2.1 import-cwd: 2.1.0 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(yaml@2.8.2): dependencies: - jiti: 1.21.7 lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 postcss: 8.5.6 + yaml: 2.8.2 postcss-loader@3.0.0: dependencies: @@ -24294,7 +24410,7 @@ snapshots: process@0.11.10: {} promise-inflight@1.0.1(bluebird@3.7.2): - dependencies: + optionalDependencies: bluebird: 3.7.2 promise-retry@2.0.1: @@ -24439,7 +24555,7 @@ snapshots: regenerator-runtime: 0.13.11 whatwg-fetch: 3.6.20 - react-dev-utils@10.2.1(eslint@9.39.2)(typescript@5.9.3)(webpack@4.41.2): + react-dev-utils@10.2.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)(webpack@4.41.2): dependencies: '@babel/code-frame': 7.8.3 address: 1.1.2 @@ -24450,7 +24566,7 @@ snapshots: escape-string-regexp: 2.0.0 filesize: 6.0.1 find-up: 4.1.0 - fork-ts-checker-webpack-plugin: 3.1.1(eslint@9.39.2)(typescript@5.9.3)(webpack@4.41.2) + fork-ts-checker-webpack-plugin: 3.1.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)(webpack@4.41.2) global-modules: 2.0.0 globby: 8.0.2 gzip-size: 5.1.1 @@ -24465,8 +24581,9 @@ snapshots: shell-quote: 1.7.2 strip-ansi: 6.0.0 text-table: 0.2.0 - typescript: 5.9.3 webpack: 4.41.2 + optionalDependencies: + typescript: 5.9.3 transitivePeerDependencies: - eslint - supports-color @@ -24506,9 +24623,10 @@ snapshots: react-lowlight@3.1.0(highlight.js@11.11.1)(react@18.3.1): dependencies: - highlight.js: 11.11.1 lowlight: 2.9.0 react: 18.3.1 + optionalDependencies: + highlight.js: 11.11.1 react-markdown@9.1.0(@types/react@18.3.27)(react@18.3.1): dependencies: @@ -24528,15 +24646,15 @@ snapshots: transitivePeerDependencies: - supports-color - react-mermaid2@0.1.4(@testing-library/dom@10.4.1)(@types/react@18.3.27)(eslint@9.39.2)(typescript@5.9.3): + react-mermaid2@0.1.4(@testing-library/dom@10.4.1)(@types/react@18.3.27)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): dependencies: '@testing-library/jest-dom': 4.2.4 - '@testing-library/react': 9.5.0(@types/react@18.3.27)(react-dom@16.14.0)(react@16.14.0) + '@testing-library/react': 9.5.0(@types/react@18.3.27)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) '@testing-library/user-event': 7.2.1(@testing-library/dom@10.4.1) mermaid: 8.14.0 react: 16.14.0 react-dom: 16.14.0(react@16.14.0) - react-scripts: 3.3.0(eslint@9.39.2)(react@16.14.0)(typescript@5.9.3) + react-scripts: 3.3.0(eslint@9.39.2(jiti@1.21.7))(react@16.14.0)(typescript@5.9.3) transitivePeerDependencies: - '@testing-library/dom' - '@types/react' @@ -24562,37 +24680,40 @@ snapshots: react-redux@9.2.0(@types/react@18.3.27)(react@18.3.1)(redux@5.0.1): dependencies: - '@types/react': 18.3.27 '@types/use-sync-external-store': 0.0.6 react: 18.3.1 - redux: 5.0.1 use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + redux: 5.0.1 react-refresh@0.17.0: {} react-remove-scroll-bar@2.3.8(@types/react@18.3.27)(react@18.3.1): dependencies: - '@types/react': 18.3.27 react: 18.3.1 react-style-singleton: 2.2.3(@types/react@18.3.27)(react@18.3.1) tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.27 react-remove-scroll@2.7.2(@types/react@18.3.27)(react@18.3.1): dependencies: - '@types/react': 18.3.27 react: 18.3.1 react-remove-scroll-bar: 2.3.8(@types/react@18.3.27)(react@18.3.1) react-style-singleton: 2.2.3(@types/react@18.3.27)(react@18.3.1) tslib: 2.8.1 use-callback-ref: 1.3.3(@types/react@18.3.27)(react@18.3.1) use-sidecar: 1.1.3(@types/react@18.3.27)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 - react-resizable-panels@2.1.9(react-dom@18.3.1)(react@18.3.1): + react-resizable-panels@2.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-router-dom@6.30.2(react-dom@18.3.1)(react@18.3.1): + react-router-dom@6.30.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@remix-run/router': 1.23.1 react: 18.3.1 @@ -24606,13 +24727,13 @@ snapshots: react-scan@0.0.4: {} - react-scripts@3.3.0(eslint@9.39.2)(react@16.14.0)(typescript@5.9.3): + react-scripts@3.3.0(eslint@9.39.2(jiti@1.21.7))(react@16.14.0)(typescript@5.9.3): dependencies: '@babel/core': 7.7.4 '@svgr/webpack': 4.3.3 - '@typescript-eslint/eslint-plugin': 2.34.0(@typescript-eslint/parser@2.34.0)(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/parser': 2.34.0(eslint@9.39.2)(typescript@5.9.3) - babel-eslint: 10.0.3(eslint@9.39.2) + '@typescript-eslint/eslint-plugin': 2.34.0(@typescript-eslint/parser@2.34.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 2.34.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + babel-eslint: 10.0.3(eslint@9.39.2(jiti@1.21.7)) babel-jest: 24.9.0(@babel/core@7.7.4) babel-loader: 8.0.6(@babel/core@7.7.4)(webpack@4.41.2) babel-plugin-named-asset-import: 0.3.8(@babel/core@7.7.4) @@ -24622,14 +24743,14 @@ snapshots: css-loader: 3.2.0(webpack@4.41.2) dotenv: 8.2.0 dotenv-expand: 5.1.0 - eslint: 9.39.2 - eslint-config-react-app: 5.2.1(@typescript-eslint/eslint-plugin@2.34.0)(@typescript-eslint/parser@2.34.0)(babel-eslint@10.0.3)(eslint-plugin-flowtype@3.13.0)(eslint-plugin-import@2.18.2)(eslint-plugin-jsx-a11y@6.2.3)(eslint-plugin-react-hooks@1.7.0)(eslint-plugin-react@7.16.0)(eslint@9.39.2)(typescript@5.9.3) - eslint-loader: 3.0.2(eslint@9.39.2)(webpack@4.41.2) - eslint-plugin-flowtype: 3.13.0(eslint@9.39.2) - eslint-plugin-import: 2.18.2(@typescript-eslint/parser@2.34.0)(eslint@9.39.2) - eslint-plugin-jsx-a11y: 6.2.3(eslint@9.39.2) - eslint-plugin-react: 7.16.0(eslint@9.39.2) - eslint-plugin-react-hooks: 1.7.0(eslint@9.39.2) + eslint: 9.39.2(jiti@1.21.7) + eslint-config-react-app: 5.2.1(@typescript-eslint/eslint-plugin@2.34.0(@typescript-eslint/parser@2.34.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/parser@2.34.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(babel-eslint@10.0.3(eslint@9.39.2(jiti@1.21.7)))(eslint-plugin-flowtype@3.13.0(eslint@9.39.2(jiti@1.21.7)))(eslint-plugin-import@2.18.2(@typescript-eslint/parser@2.34.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)))(eslint-plugin-jsx-a11y@6.2.3(eslint@9.39.2(jiti@1.21.7)))(eslint-plugin-react-hooks@1.7.0(eslint@9.39.2(jiti@1.21.7)))(eslint-plugin-react@7.16.0(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + eslint-loader: 3.0.2(eslint@9.39.2(jiti@1.21.7))(webpack@4.41.2) + eslint-plugin-flowtype: 3.13.0(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-import: 2.18.2(@typescript-eslint/parser@2.34.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-jsx-a11y: 6.2.3(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-react: 7.16.0(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-react-hooks: 1.7.0(eslint@9.39.2(jiti@1.21.7)) file-loader: 4.3.0(webpack@4.41.2) fs-extra: 8.1.0 html-webpack-plugin: 4.0.0-beta.5(webpack@4.41.2) @@ -24648,7 +24769,7 @@ snapshots: postcss-safe-parser: 4.0.1 react: 16.14.0 react-app-polyfill: 1.0.6 - react-dev-utils: 10.2.1(eslint@9.39.2)(typescript@5.9.3)(webpack@4.41.2) + react-dev-utils: 10.2.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)(webpack@4.41.2) resolve: 1.12.2 resolve-url-loader: 3.1.1 sass-loader: 8.0.0(webpack@4.41.2) @@ -24656,14 +24777,14 @@ snapshots: style-loader: 1.0.0(webpack@4.41.2) terser-webpack-plugin: 2.2.1(webpack@4.41.2) ts-pnp: 1.1.5(typescript@5.9.3) - typescript: 5.9.3 - url-loader: 2.3.0(file-loader@4.3.0)(webpack@4.41.2) + url-loader: 2.3.0(file-loader@4.3.0(webpack@4.41.2))(webpack@4.41.2) webpack: 4.41.2 webpack-dev-server: 3.9.0(webpack@4.41.2) webpack-manifest-plugin: 2.2.0(webpack@4.41.2) workbox-webpack-plugin: 4.3.1(webpack@4.41.2) optionalDependencies: fsevents: 2.1.2 + typescript: 5.9.3 transitivePeerDependencies: - bluebird - bufferutil @@ -24680,10 +24801,11 @@ snapshots: react-style-singleton@2.2.3(@types/react@18.3.27)(react@18.3.1): dependencies: - '@types/react': 18.3.27 get-nonce: 1.0.1 react: 18.3.1 tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.27 react-syntax-highlighter@15.6.6(react@18.3.1): dependencies: @@ -24695,12 +24817,13 @@ snapshots: react: 18.3.1 refractor: 3.6.0 - react-use-measure@2.1.7(react-dom@18.3.1)(react@18.3.1): + react-use-measure@2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 + optionalDependencies: react-dom: 18.3.1(react@18.3.1) - react-window@1.8.11(react-dom@18.3.1)(react@18.3.1): + react-window@1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@babel/runtime': 7.28.4 memoize-one: 5.2.1 @@ -24771,11 +24894,11 @@ snapshots: dependencies: picomatch: 2.3.1 - reagraph@4.30.7(@types/react@18.3.27)(@types/three@0.182.0)(graphology-types@0.24.8)(immer@10.2.0)(react-dom@18.3.1)(react@18.3.1): + reagraph@4.30.7(@types/react@18.3.27)(@types/three@0.182.0)(graphology-types@0.24.8)(immer@10.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)): dependencies: - '@react-spring/three': 10.0.3(@react-three/fiber@9.3.0)(react@18.3.1)(three@0.180.0) - '@react-three/drei': 10.7.7(@react-three/fiber@9.3.0)(@types/react@18.3.27)(@types/three@0.182.0)(immer@10.2.0)(react-dom@18.3.1)(react@18.3.1)(three@0.180.0) - '@react-three/fiber': 9.3.0(@types/react@18.3.27)(immer@10.2.0)(react-dom@18.3.1)(react@18.3.1)(three@0.180.0) + '@react-spring/three': 10.0.3(@react-three/fiber@9.3.0(@types/react@18.3.27)(immer@10.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.180.0))(react@18.3.1)(three@0.180.0) + '@react-three/drei': 10.7.7(@react-three/fiber@9.3.0(@types/react@18.3.27)(immer@10.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.180.0))(@types/react@18.3.27)(@types/three@0.182.0)(immer@10.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.180.0) + '@react-three/fiber': 9.3.0(@types/react@18.3.27)(immer@10.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.180.0) '@use-gesture/react': 10.3.1(react@18.3.1) camera-controls: 3.1.2(three@0.180.0) classnames: 2.5.1 @@ -24795,7 +24918,7 @@ snapshots: react-dom: 18.3.1(react@18.3.1) three: 0.180.0 three-stdlib: 2.36.1(three@0.180.0) - zustand: 5.0.8(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1) + zustand: 5.0.8(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) transitivePeerDependencies: - '@types/react' - '@types/three' @@ -25239,16 +25362,16 @@ snapshots: select-hose@2.0.0: {} - selection-popover@0.3.0(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1): + selection-popover@0.3.0(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@floating-ui/react-dom': 1.3.0(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-arrow': 1.0.1(react-dom@18.3.1)(react@18.3.1) + '@floating-ui/react-dom': 1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-arrow': 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) '@radix-ui/react-context': 1.0.0(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.0.4(@types/react-dom@18.3.7)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-portal': 1.0.1(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-presence': 1.0.0(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.1(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.0.4(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.0.0(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.0.0(react@18.3.1) '@radix-ui/react-use-size': 1.0.0(react@18.3.1) @@ -25548,7 +25671,7 @@ snapshots: smart-buffer: 4.2.0 optional: true - sonner@2.0.7(react-dom@18.3.1)(react@18.3.1): + sonner@2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -25997,11 +26120,11 @@ snapshots: tailwind-merge@2.6.0: {} - tailwindcss-animate@1.0.7(tailwindcss@3.4.19): + tailwindcss-animate@1.0.7(tailwindcss@3.4.19(yaml@2.8.2)): dependencies: - tailwindcss: 3.4.19 + tailwindcss: 3.4.19(yaml@2.8.2) - tailwindcss@3.4.19: + tailwindcss@3.4.19(yaml@2.8.2): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -26020,7 +26143,7 @@ snapshots: postcss: 8.5.6 postcss-import: 15.1.0(postcss@8.5.6) postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(yaml@2.8.2) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 resolve: 1.22.11 @@ -26255,13 +26378,12 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.4.6(@babel/core@7.7.4)(jest@29.7.0)(typescript@5.9.3): + ts-jest@29.4.6(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.19.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)))(typescript@5.9.3): dependencies: - '@babel/core': 7.7.4 bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.8 - jest: 29.7.0(@types/node@22.19.3)(ts-node@10.9.2) + jest: 29.7.0(@types/node@22.19.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -26269,6 +26391,12 @@ snapshots: type-fest: 4.41.0 typescript: 5.9.3 yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.28.5 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.28.5) + jest-util: 29.7.0 ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3): dependencies: @@ -26289,7 +26417,7 @@ snapshots: yn: 3.1.1 ts-pnp@1.1.5(typescript@5.9.3): - dependencies: + optionalDependencies: typescript: 5.9.3 tsconfig-paths@3.15.0: @@ -26392,13 +26520,13 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.49.0(eslint@9.39.2)(typescript@5.9.3): + typescript-eslint@8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.49.0(@typescript-eslint/parser@8.49.0)(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/parser': 8.49.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.49.0(eslint@9.39.2)(typescript@5.9.3) - eslint: 9.39.2 + '@typescript-eslint/utils': 8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -26527,13 +26655,14 @@ snapshots: urix@0.1.0: {} - url-loader@2.3.0(file-loader@4.3.0)(webpack@4.41.2): + url-loader@2.3.0(file-loader@4.3.0(webpack@4.41.2))(webpack@4.41.2): dependencies: - file-loader: 4.3.0(webpack@4.41.2) loader-utils: 1.4.2 mime: 2.6.0 schema-utils: 2.7.1 webpack: 4.41.2 + optionalDependencies: + file-loader: 4.3.0(webpack@4.41.2) url-parse@1.5.10: dependencies: @@ -26547,9 +26676,10 @@ snapshots: use-callback-ref@1.3.3(@types/react@18.3.27)(react@18.3.1): dependencies: - '@types/react': 18.3.27 react: 18.3.1 tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.27 use-editable@2.3.3(react@18.3.1): dependencies: @@ -26559,7 +26689,7 @@ snapshots: dependencies: react: 18.3.1 - use-react-query-auto-sync@0.1.0(@tanstack/react-query@5.90.12)(react-dom@18.3.1)(react@18.3.1): + use-react-query-auto-sync@0.1.0(@tanstack/react-query@5.90.12(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@tanstack/react-query': 5.90.12(react@18.3.1) lodash.debounce: 4.0.8 @@ -26568,10 +26698,11 @@ snapshots: use-sidecar@1.1.3(@types/react@18.3.27)(react@18.3.1): dependencies: - '@types/react': 18.3.27 detect-node-es: 1.1.0 react: 18.3.1 tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.27 use-sync-external-store@1.6.0(react@18.3.1): dependencies: @@ -26703,9 +26834,9 @@ snapshots: - supports-color - terser - vite-plugin-node-polyfills@0.22.0(vite@5.4.21): + vite-plugin-node-polyfills@0.22.0(rollup@4.53.4)(vite@5.4.21(@types/node@22.19.3)): dependencies: - '@rollup/plugin-inject': 5.0.5 + '@rollup/plugin-inject': 5.0.5(rollup@4.53.4) node-stdlib-browser: 1.3.1 vite: 5.4.21(@types/node@22.19.3) transitivePeerDependencies: @@ -26713,18 +26844,17 @@ snapshots: vite@5.4.21(@types/node@22.19.3): dependencies: - '@types/node': 22.19.3 esbuild: 0.21.5 postcss: 8.5.6 rollup: 4.53.4 optionalDependencies: + '@types/node': 22.19.3 fsevents: 2.3.3 - vitest@2.1.9(@types/node@22.19.3): + vitest@2.1.9(@types/node@22.19.3)(jsdom@14.1.0): dependencies: - '@types/node': 22.19.3 '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21) + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.3)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -26743,6 +26873,9 @@ snapshots: vite: 5.4.21(@types/node@22.19.3) vite-node: 2.1.9(@types/node@22.19.3) why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.3 + jsdom: 14.1.0 transitivePeerDependencies: - less - lightningcss @@ -26827,7 +26960,7 @@ snapshots: del: 4.1.1 express: 4.22.1(supports-color@6.1.0) html-entities: 1.4.0 - http-proxy-middleware: 0.19.1(debug@4.4.3)(supports-color@6.1.0) + http-proxy-middleware: 0.19.1(debug@4.4.3(supports-color@6.1.0))(supports-color@6.1.0) import-local: 2.0.0 internal-ip: 4.3.0 ip: 1.1.9 @@ -27234,19 +27367,21 @@ snapshots: zustand@4.5.7(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1): dependencies: + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: '@types/react': 18.3.27 immer: 10.2.0 react: 18.3.1 - use-sync-external-store: 1.6.0(react@18.3.1) - zustand@5.0.8(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1): - dependencies: + zustand@5.0.8(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)): + optionalDependencies: '@types/react': 18.3.27 immer: 10.2.0 react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) - zustand@5.0.9(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(use-sync-external-store@1.6.0): - dependencies: + zustand@5.0.9(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)): + optionalDependencies: '@types/react': 18.3.27 immer: 10.2.0 react: 18.3.1 diff --git a/src-tauri/src/migrations.rs b/src-tauri/src/migrations.rs index 87622999..0ce69ffc 100644 --- a/src-tauri/src/migrations.rs +++ b/src-tauri/src/migrations.rs @@ -2554,5 +2554,122 @@ You have full access to bash commands on the user''''s computer. If you write a UPDATE projects SET total_cost_usd = 0.0 WHERE total_cost_usd IS NULL; "#, }, + Migration { + version: 139, + description: "add provider_visible_models table", + kind: MigrationKind::Up, + sql: r#" + CREATE TABLE provider_visible_models ( + provider_name TEXT NOT NULL, + model_id TEXT NOT NULL, + is_visible BOOLEAN DEFAULT 1, + PRIMARY KEY (provider_name, model_id) + ); + "#, + }, + Migration { + version: 140, + description: "add model_profiles table", + kind: MigrationKind::Up, + sql: r#" + CREATE TABLE model_profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + model_config_ids TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + "#, + }, + Migration { + version: 141, + description: "add prompt_profiles and prompt_profile_chats tables", + kind: MigrationKind::Up, + sql: r#" + CREATE TABLE prompt_profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + system_prompt TEXT NOT NULL, + icon TEXT, + author TEXT NOT NULL DEFAULT 'user' CHECK (author IN ('user', 'system')), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE prompt_profile_chats ( + id TEXT PRIMARY KEY, + chat_id TEXT NOT NULL UNIQUE, + prompt_profile_id TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO prompt_profiles (id, name, system_prompt, icon, author) VALUES + ('pp-data-scientist', 'Data Scientist', 'You are an expert data scientist. Focus on statistical rigor, reproducibility, and evidence-based reasoning. Prefer concrete numbers and quantitative analysis. Use Python or R code examples when helpful, following best practices for data manipulation, visualization, and modeling.', '🔬', 'system'), + ('pp-academic-researcher', 'Academic Researcher', 'You are an academic researcher. Prioritize primary sources, peer-reviewed literature, and rigorous methodology. Structure responses with clear argumentation. Cite relevant work and distinguish between established findings and emerging hypotheses. Use precise academic language.', '🎓', 'system'), + ('pp-study-guide', 'Study Guide', 'You are a patient and encouraging tutor. Break down complex topics into clear, digestible explanations. Use examples, analogies, and step-by-step reasoning to build understanding. Ask clarifying questions to gauge comprehension and adapt your explanations to the learner''s level. Suggest practice questions when appropriate.', '📚', 'system'), + ('pp-code-reviewer', 'Code Reviewer', 'You are a meticulous code reviewer. Focus on correctness, performance, security, and maintainability. Point out bugs, edge cases, and anti-patterns. Suggest concrete improvements with explanations. Consider readability and adherence to best practices. Be constructive but thorough.', '🔍', 'system'), + ('pp-creative-writer', 'Creative Writer', 'You are a skilled creative writing partner. Focus on vivid, engaging language, compelling narrative, and emotional resonance. Help with brainstorming ideas, developing characters, structuring plots, and refining prose. Offer specific suggestions rather than vague encouragement.', '✏️', 'system'); + "#, + }, + Migration { + version: 142, + description: "update ambient gemini to gemini 2.5 flash", + kind: MigrationKind::Up, + sql: r#" + -- Add Ambient Gemini Flash config using Gemini 2.5 Flash + INSERT OR REPLACE INTO model_configs (author, id, model_id, display_name, system_prompt, is_default) VALUES + ('user', 'google::ambient-gemini-2.5-flash', 'google::gemini-2.5-flash-preview-04-17', 'Ambient Gemini Flash', + 'Respond concisely. Use one or two sentences if possible. + +If you see a screenshot, it means the system has automatically attached a screenshot showing the current user''''s computer screen. Use these screenshots as needed to help answer the user''''s questions. There''''s no need to describe the screenshot or comment on it unless it relates to the user''''s question. + +If you cannot see a screenshot, it means the user has disabled vision mode, and if they ask something that requires a screenshot, you should ask them to enable vision mode. + +You have full access to bash commands on the user''''s computer. If you write a bash command in a ```sh markdown block, the user will be able to click ''run'' to quickly execute the command. Use this to help answer questions or perform tasks if it''''s relevant. Assume a MacOS environment.', + 0); + + -- Migrate users still on the deprecated ambient Gemini config + UPDATE app_metadata SET value = 'google::ambient-gemini-2.5-flash' + WHERE key = 'quick_chat_model_config_id' + AND value = 'google::ambient-gemini-2.5-pro-preview-03-25'; + + -- Mark old ambient config as deprecated + UPDATE model_configs SET display_name = 'Ambient Gemini (Deprecated)' + WHERE id = 'google::ambient-gemini-2.5-pro-preview-03-25'; + "#, + }, + Migration { + version: 144, + description: "add default_prompt_profile_id to projects", + kind: MigrationKind::Up, + sql: r#" + ALTER TABLE projects ADD COLUMN default_prompt_profile_id TEXT DEFAULT NULL; + "#, + }, + Migration { + version: 143, + description: "add gemini 2.5 flash lite and update ambient to use it", + kind: MigrationKind::Up, + sql: r#" + -- Add Gemini 2.5 Flash Lite (stable) + INSERT OR REPLACE INTO models (id, display_name, is_enabled, supported_attachment_types) VALUES + ('google::gemini-2.5-flash-lite', 'Gemini 2.5 Flash Lite', 1, '["text", "image", "webpage"]'); + + INSERT OR REPLACE INTO model_configs (author, id, model_id, display_name, system_prompt, is_default) VALUES + ('system', 'google::gemini-2.5-flash-lite', 'google::gemini-2.5-flash-lite', 'Gemini 2.5 Flash Lite', '', 0); + + -- Deprecate old Gemini 2.0 Flash Lite preview + UPDATE models SET display_name = 'Gemini 2.0 Flash Lite (Deprecated)' + WHERE id = 'google::gemini-2.0-flash-lite-preview-02-05'; + + UPDATE model_configs SET display_name = 'Gemini 2.0 Flash Lite (Deprecated)' + WHERE id = 'google::gemini-2.0-flash-lite-preview-02-05'; + + -- Update ambient config to use the stable flash-lite model + UPDATE model_configs SET model_id = 'google::gemini-2.5-flash-lite' + WHERE id = 'google::ambient-gemini-2.5-flash'; + "#, + }, ]; } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7731812e..d391a0ee 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2.0.2", "productName": "Chorus", - "version": "0.14.5", + "version": "0.14.14", "identifier": "sh.chorus.app", "build": { "beforeDevCommand": "pnpm vite:dev", diff --git a/src-tauri/tauri.dev.conf.json b/src-tauri/tauri.dev.conf.json index ed8edf97..01a00d28 100644 --- a/src-tauri/tauri.dev.conf.json +++ b/src-tauri/tauri.dev.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2.0.2", "productName": "Chorus", - "version": "0.14.5", + "version": "0.14.14", "identifier": "sh.chorus.app.dev", "build": { "beforeDevCommand": "pnpm vite:dev", diff --git a/src-tauri/tauri.qa.conf.json b/src-tauri/tauri.qa.conf.json index 45cf6442..614b1a06 100644 --- a/src-tauri/tauri.qa.conf.json +++ b/src-tauri/tauri.qa.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2.0.2", "productName": "Chorus Nightly", - "version": "0.14.5", + "version": "0.14.14", "identifier": "sh.chorus.app.qa", "build": { "beforeDevCommand": "pnpm vite:dev", diff --git a/src/core/chorus/ChatCompareSelection.ts b/src/core/chorus/ChatCompareSelection.ts new file mode 100644 index 00000000..bba14aa1 --- /dev/null +++ b/src/core/chorus/ChatCompareSelection.ts @@ -0,0 +1,140 @@ +import { getFilteredModelConfigs } from "@core/utilities/ModelFiltering"; +import { SettingsManager, type Settings } from "@core/utilities/Settings"; +import { db } from "./DB"; +import { fetchModelConfigs, fetchModelConfigsCompare } from "./api/ModelsAPI"; +import { fetchProviderVisibleModels } from "./api/ProviderVisibilityAPI"; +import { + fetchActiveModelProfileId, + fetchModelProfiles, +} from "./api/ModelProfilesAPI"; +import type { ModelConfig, ProviderVisibility } from "./Models"; + +function providerVisibilityMap( + rows: ProviderVisibility[], +): Map { + return new Map(rows.map((v) => [v.modelId, v.isVisible])); +} + +/** + * Model configs the user can pick (same rules as the model picker). + */ +export async function fetchVisibleModelConfigsForSelection(): Promise< + ModelConfig[] +> { + const [all, visibilityRows, profiles, activeId] = await Promise.all([ + fetchModelConfigs(), + fetchProviderVisibleModels(), + fetchModelProfiles(), + fetchActiveModelProfileId(), + ]); + const map = providerVisibilityMap(visibilityRows); + const active = activeId + ? (profiles.find((p) => p.id === activeId) ?? null) + : null; + return getFilteredModelConfigs(all, map, active); +} + +async function resolveDefaultFallbackConfigId( + settings: Settings, + visibleIds: Set, +): Promise { + const fallbackId = settings.defaultFallbackModel ?? undefined; + if (!fallbackId || !visibleIds.has(fallbackId)) { + return undefined; + } + const profileId = settings.defaultFallbackModelProfileId; + if (profileId) { + const profiles = await fetchModelProfiles(); + const prof = profiles.find((p) => p.id === profileId); + if (prof && prof.modelConfigIds.includes(fallbackId)) { + return fallbackId; + } + return undefined; + } + return fallbackId; +} + +/** + * Priority for new regular chats (no explicit default chat models): + * 1. defaultChatModels (multi, when non-empty after visibility filter) + * 2. defaultFallbackModel (single baseline — used before global ambient compare) + * 3. ambient (global compare picker / ⌘J list) + * 4. first visible model + * + * Fallback was previously evaluated only after ambient; that meant any non-empty + * ambient list hid the fallback entirely. + */ +export async function computeInitialChatCompareModelConfigIds(): Promise< + string[] +> { + const visible = await fetchVisibleModelConfigsForSelection(); + const visibleIds = new Set(visible.map((c) => c.id)); + + const settings = await SettingsManager.getInstance().get(); + const configured = (settings.defaultChatModels ?? []).filter((id) => + visibleIds.has(id), + ); + if (configured.length > 0) { + return configured; + } + + const fallbackId = await resolveDefaultFallbackConfigId( + settings, + visibleIds, + ); + if (fallbackId) { + return [fallbackId]; + } + + const ambient = await fetchModelConfigsCompare(); + const filtered = ambient.filter((m) => visibleIds.has(m.id)); + if (filtered.length > 0) { + return filtered.map((m) => m.id); + } + + if (visible.length > 0) { + return [visible[0].id]; + } + + return []; +} + +export function resolveOrderedCompareConfigs( + savedIds: string[] | null | undefined, + allConfigs: ModelConfig[], + visibleConfigs: ModelConfig[], +): ModelConfig[] { + const visibleIds = new Set(visibleConfigs.map((c) => c.id)); + const byId = new Map(allConfigs.map((c) => [c.id, c])); + if (!savedIds?.length) { + return []; + } + const ordered: ModelConfig[] = []; + for (const id of savedIds) { + if (!visibleIds.has(id)) continue; + const cfg = byId.get(id); + if (cfg) ordered.push(cfg); + } + return ordered; +} + +/** + * Keeps app_metadata compare in sync so "ambient" defaults for new chats match + * the last explicit multi-model selection from a regular chat. + */ +export async function syncGlobalCompareMetadataToConfigIds( + orderedConfigIds: string[], + allConfigs: ModelConfig[], +): Promise { + const byId = new Map(allConfigs.map((c) => [c.id, c])); + const ordered = orderedConfigIds + .map((id) => byId.get(id)) + .filter((m): m is ModelConfig => m !== undefined); + if (ordered.length === 0) { + return; + } + await db.execute( + "UPDATE app_metadata SET value = ? WHERE key = 'selected_model_configs_compare'", + [JSON.stringify(ordered.map((m) => m.id))], + ); +} diff --git a/src/core/chorus/ModelProviders/ProviderGoogle.ts b/src/core/chorus/ModelProviders/ProviderGoogle.ts index 9547397b..4dda96ad 100644 --- a/src/core/chorus/ModelProviders/ProviderGoogle.ts +++ b/src/core/chorus/ModelProviders/ProviderGoogle.ts @@ -36,6 +36,7 @@ function getGoogleModelName(modelName: string): string | undefined { "gemini-2.0-flash", "gemini-2.5-pro-preview-03-25", "gemini-2.5-flash", + "gemini-2.5-flash-lite", "gemini-3-flash-preview", "gemini-3-pro-preview", ].includes(modelName) diff --git a/src/core/chorus/ModelProviders/simple/SimpleCompletionProviderFactory.ts b/src/core/chorus/ModelProviders/simple/SimpleCompletionProviderFactory.ts index d81ffc47..5a31117a 100644 --- a/src/core/chorus/ModelProviders/simple/SimpleCompletionProviderFactory.ts +++ b/src/core/chorus/ModelProviders/simple/SimpleCompletionProviderFactory.ts @@ -35,6 +35,21 @@ const PROVIDER_PRECEDENCE: ProviderConfig[] = [ }, ]; +/** + * Creates a provider for the given provider prefix (e.g. "anthropic", "openrouter") using + * the matching API key. Returns null if the prefix is unknown or the key is missing. + */ +export function createProviderByPrefix( + prefix: string, + apiKeys: ApiKeys, +): ISimpleCompletionProvider | null { + const config = PROVIDER_PRECEDENCE.find((p) => p.name === prefix); + if (!config) return null; + const apiKey = apiKeys[config.key]; + if (!apiKey) return null; + return config.create(apiKey); +} + /** * Factory function that selects and returns an appropriate simple completion provider * based on available API keys. Follows explicit precedence order. diff --git a/src/core/chorus/Models.ts b/src/core/chorus/Models.ts index 1563d354..da41e4ec 100644 --- a/src/core/chorus/Models.ts +++ b/src/core/chorus/Models.ts @@ -206,6 +206,45 @@ export type ModelConfig = { completionPricePerToken?: number; }; +/// ------------------------------------------------------------------------------------------------ +/// Provider Visibility & Model Profiles +/// ------------------------------------------------------------------------------------------------ + +/** + * Per-model visibility setting for provider-level filtering. + * Users can hide models they don't want to see in the model picker. + */ +export type ProviderVisibility = { + providerName: string; + modelId: string; + isVisible: boolean; +}; + +/** + * A named profile containing a set of selected model configs. + * Users can quickly switch between profiles (e.g., "3 model set", "4 model set"). + */ +export type ModelProfile = { + id: string; + name: string; + modelConfigIds: string[]; // Ordered list of model config IDs + createdAt?: string; + updatedAt?: string; +}; + +/** + * A named persona/role preset with a system prompt injected into chats. + */ +export type PromptProfile = { + id: string; + name: string; + systemPrompt: string; + icon?: string; + author: "user" | "system"; + createdAt?: string; + updatedAt?: string; +}; + export type UsageData = { prompt_tokens?: number; completion_tokens?: number; diff --git a/src/core/chorus/api/ChatAPI.ts b/src/core/chorus/api/ChatAPI.ts index e9122dfe..c7105819 100644 --- a/src/core/chorus/api/ChatAPI.ts +++ b/src/core/chorus/api/ChatAPI.ts @@ -1,9 +1,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { produce } from "immer"; import { useNavigate } from "react-router-dom"; +import { computeInitialChatCompareModelConfigIds } from "../ChatCompareSelection"; import { db } from "../DB"; import { getVersion } from "@tauri-apps/api/app"; import { usePostHog } from "posthog-js/react"; +import { updateSavedModelConfigChat } from "./ModelConfigChatAPI"; +import { applyCreationDefaultsForNewChatRow } from "../chatCreationDefaults"; const chatKeys = { all: () => ["chats"] as const, @@ -207,7 +210,20 @@ export function useCreateNewChat() { if (!result.length) { throw new Error("Failed to create chat"); } - return result[0].id; + const chatId = result[0].id; + if (projectId !== "quick-chat") { + try { + const compareIds = + await computeInitialChatCompareModelConfigIds(); + await updateSavedModelConfigChat(chatId, compareIds); + } catch (err) { + console.error( + "Failed to initialize compare selection for chat", + { chatId, projectId, error: err }, + ); + } + } + return chatId; }, onSuccess: async (chatId: string) => { await queryClient.invalidateQueries(chatQueries.list()); @@ -218,6 +234,8 @@ export function useCreateNewChat() { posthog?.capture("chat_created", { version, }); + + await applyCreationDefaultsForNewChatRow(chatId, queryClient); }, }); } @@ -239,7 +257,18 @@ export function useCreateGroupChat() { if (!result.length) { throw new Error("Failed to create group chat"); } - return result[0].id; + const chatId = result[0].id; + try { + const compareIds = + await computeInitialChatCompareModelConfigIds(); + await updateSavedModelConfigChat(chatId, compareIds); + } catch (error) { + console.error( + "Failed to initialize group chat compare model configs", + error, + ); + } + return chatId; }, onSuccess: async (chatId: string) => { await queryClient.invalidateQueries(chatQueries.list()); diff --git a/src/core/chorus/api/MessageAPI.ts b/src/core/chorus/api/MessageAPI.ts index 013cd067..ddc8cc4e 100644 --- a/src/core/chorus/api/MessageAPI.ts +++ b/src/core/chorus/api/MessageAPI.ts @@ -32,8 +32,11 @@ import _ from "lodash"; import { useAppContext } from "@ui/hooks/useAppContext"; import { db } from "../DB"; import { draftKeys } from "./DraftAPI"; -import { updateSavedModelConfigChat } from "./ModelConfigChatAPI"; -import { chatIsLoadingQueries, chatQueries } from "./ChatAPI"; +import { + fetchSavedModelConfigChat, + updateSavedModelConfigChat, +} from "./ModelConfigChatAPI"; +import { Chat, chatIsLoadingQueries, chatQueries } from "./ChatAPI"; import { appMetadataKeys, getApiKeys, @@ -57,7 +60,15 @@ import { useModelConfigsPromise, fetchModelConfigById, } from "./ModelsAPI"; +import { SettingsManager } from "@core/utilities/Settings"; import { Attachment, AttachmentDBRow, readAttachment } from "./AttachmentsAPI"; +import { fetchChatPromptProfileSystemPrompt } from "./PromptProfilesAPI"; +import { toolsDisabledActions } from "@core/infra/ToolsDisabledStore"; +import { + buildProviderVisibilityMap, + isModelConfigEffectivelyVisible, + modelConfigSupportsVision, +} from "../chatCreationDefaults"; // Query keys objects are based on https://tkdodo.eu/blog/effective-react-query-keys // although also consider this approach: https://tkdodo.eu/blog/leveraging-the-query-function-context @@ -320,6 +331,79 @@ export async function fetchMessage(messageId: string): Promise { return readMessage(messageRow, messageParts, attachments); } +function isFailedMessage(message: Message | null): boolean { + if (!message) return false; + if (message.errorMessage) return true; + if (message.state !== "idle") return false; + + const hasText = message.text.trim().length > 0; + const hasPartContent = message.parts.some( + (part) => part.content.trim().length > 0, + ); + return !hasText && !hasPartContent; +} + +const TOOL_UNSUPPORTED_ERROR_PATTERNS = [ + "does not support tool", + "doesn't support tool", + "tool use is not supported", + "tools are not supported", + "tool calls are not supported", + "function calling is not supported", + "unsupported parameter: 'tools'", + "unknown parameter: tools", + "unsupported parameter: tools", + "tool_choice", +]; + +function isToolUseUnsupportedError(errorMessage: string | undefined): boolean { + if (!errorMessage) return false; + const lowerMessage = errorMessage.toLowerCase(); + return TOOL_UNSUPPORTED_ERROR_PATTERNS.some((pattern) => + lowerMessage.includes(pattern), + ); +} + +function isMediaAttachmentType(type: Attachment["type"]): boolean { + return type === "image" || type === "pdf"; +} + +function hasUnsupportedMediaForModel( + messageSets: MessageSetDetail[], + draftAttachmentTypes: Attachment["type"][], + modelConfig: ModelConfig, +): boolean { + const allAttachmentTypes: Attachment["type"][] = [ + ...messageSets.flatMap( + (messageSet) => + messageSet.userBlock.message?.attachments?.map( + (attachment) => attachment.type, + ) ?? [], + ), + ...draftAttachmentTypes, + ]; + + return allAttachmentTypes.some( + (attachmentType) => + isMediaAttachmentType(attachmentType) && + !modelConfig.supportedAttachmentTypes.includes(attachmentType), + ); +} + +async function fetchDraftAttachmentTypes( + chatId: string, +): Promise { + return ( + await db.select<{ type: Attachment["type"] }[]>( + `SELECT attachments.type + FROM draft_attachments + JOIN attachments ON draft_attachments.attachment_id = attachments.id + WHERE draft_attachments.chat_id = ?`, + [chatId], + ) + ).map((row) => row.type); +} + export async function fetchMessageDraft( chatId: string, ): Promise { @@ -596,11 +680,13 @@ export function useMessageSet(chatId: string, messageSetId: string) { export function useMessageSets( chatId: string, select?: (data: MessageSetDetail[]) => MessageSetDetail[], + options?: { enabled?: boolean }, ) { return useQuery({ select, queryKey: messageKeys.messageSets(chatId), queryFn: () => fetchMessageSets(chatId), + enabled: options?.enabled, }); } @@ -810,6 +896,55 @@ export function useRestartMessage( }: { modelConfig: Models.ModelConfig; }) => { + const originalMessage = await fetchMessage(messageId); + const cachedDraft = queryClient.getQueryData( + draftKeys.messageDraft(chatId), + ); + const currentDraft = ( + cachedDraft ?? + (await fetchMessageDraft(chatId)) ?? + "" + ).trim(); + const shouldUseDraftForRegenerate = + Boolean(originalMessage?.selected) && + isFailedMessage(originalMessage) && + currentDraft.length > 0; + const shouldDisableToolsForRetry = + toolsDisabledActions.isToolsDisabledForModel( + chatId, + modelConfig.id, + ) || isToolUseUnsupportedError(originalMessage?.errorMessage); + + if (shouldDisableToolsForRetry) { + toolsDisabledActions.disableToolsForModel( + chatId, + modelConfig.id, + ); + + const [messageSets, draftAttachmentTypes] = await Promise.all([ + fetchMessageSets(chatId), + fetchDraftAttachmentTypes(chatId), + ]); + + // no-tools models often cannot handle image/pdf context either + if ( + hasUnsupportedMediaForModel( + messageSets, + draftAttachmentTypes, + modelConfig, + ) + ) { + console.warn( + "Skipping no-tools retry due to unsupported media attachments", + { + chatId, + modelConfigId: modelConfig.id, + }, + ); + return undefined; + } + } + const streamingToken = uuidv4(); const lockResult = await db.execute( `UPDATE messages @@ -844,12 +979,24 @@ export function useRestartMessage( queryKey: messageKeys.messageSets(chatId), }); + if (shouldUseDraftForRegenerate) { + await db.execute( + "INSERT OR REPLACE INTO message_drafts (chat_id, content) VALUES ($1, $2)", + [chatId, ""], + ); + queryClient.setQueryData(draftKeys.messageDraft(chatId), ""); + } + await streamToolsMessage.mutateAsync({ chatId, messageSetId, messageId, streamingToken, modelConfig, + draftUserInput: shouldUseDraftForRegenerate + ? currentDraft + : undefined, + disableTools: shouldDisableToolsForRetry, }); return streamingToken; @@ -889,6 +1036,20 @@ export function useRestartMessageLegacy( }: { modelConfig: Models.ModelConfig; }) => { + const originalMessage = await fetchMessage(messageId); + const cachedDraft = queryClient.getQueryData( + draftKeys.messageDraft(chatId), + ); + const currentDraft = ( + cachedDraft ?? + (await fetchMessageDraft(chatId)) ?? + "" + ).trim(); + const shouldUseDraftForRegenerate = + Boolean(originalMessage?.selected) && + isFailedMessage(originalMessage) && + currentDraft.length > 0; + const streamingToken = uuidv4(); const result = await db.execute( `UPDATE messages @@ -916,11 +1077,30 @@ export function useRestartMessageLegacy( queryKey: messageKeys.messageSets(chatId), }); + if (shouldUseDraftForRegenerate) { + await db.execute( + "INSERT OR REPLACE INTO message_drafts (chat_id, content) VALUES ($1, $2)", + [chatId, ""], + ); + queryClient.setQueryData(draftKeys.messageDraft(chatId), ""); + } + const messageSets = await getMessageSets(chatId); // assume this is the last message set const previousMessageSets = messageSets?.slice(0, -1); - const conversation = llmConversation(previousMessageSets); + const conversation = [ + ...llmConversation(previousMessageSets), + ...(shouldUseDraftForRegenerate + ? [ + { + role: "user" as const, + content: currentDraft, + attachments: [], + }, + ] + : []), + ]; await streamMessageText.mutateAsync({ chatId, @@ -1285,6 +1465,8 @@ export function useStreamMessagePart() { queryKey: appMetadataKeys.appMetadata(), queryFn: () => fetchAppMetadata(), }); + const promptProfileSystemPrompt = + await fetchChatPromptProfileSystemPrompt(chatId); const modelConfig = Prompts.injectSystemPrompts(modelConfigRaw, { toolsetInfo: toolsets.map((toolset) => ({ displayName: toolset.displayName, @@ -1293,6 +1475,7 @@ export function useStreamMessagePart() { })), isInProject: project.id !== "default", universalSystemPrompt: appMetadata["universal_system_prompt"], + promptProfileSystemPrompt, }); const customBaseUrl = await getCustomBaseUrl(); @@ -1364,9 +1547,12 @@ export function useStreamMessageLegacy() { queryKey: appMetadataKeys.appMetadata(), queryFn: () => fetchAppMetadata(), }); + const promptProfileSystemPrompt = + await fetchChatPromptProfileSystemPrompt(chatId); const modelConfig = Prompts.injectSystemPrompts(modelConfigRaw, { isInProject: project.id !== "default", universalSystemPrompt: appMetadata["universal_system_prompt"], + promptProfileSystemPrompt, }); const projectContext = await getProjectContext(project.id, chatId); @@ -1910,6 +2096,66 @@ export function useSelectMessage() { }); } +export function useDeselectToolsMessages() { + const queryClient = useQueryClient(); + const markProjectContextSummaryAsStale = + useMarkProjectContextSummaryAsStale(); + + return useMutation({ + mutationKey: ["deselectToolsMessages"] as const, + mutationFn: async ({ + messageSetId, + }: { + chatId: string; + messageSetId: string; + }) => { + await db.execute( + "UPDATE messages SET selected = 0 WHERE message_set_id = ? AND block_type = 'tools'", + [messageSetId], + ); + }, + onSuccess: async (_data, variables) => { + await queryClient.invalidateQueries({ + queryKey: messageKeys.messageSets(variables.chatId), + }); + + await markProjectContextSummaryAsStale.mutateAsync({ + chatId: variables.chatId, + }); + }, + }); +} + +export function useDeselectCompareMessages() { + const queryClient = useQueryClient(); + const markProjectContextSummaryAsStale = + useMarkProjectContextSummaryAsStale(); + + return useMutation({ + mutationKey: ["deselectCompareMessages"] as const, + mutationFn: async ({ + messageSetId, + }: { + chatId: string; + messageSetId: string; + }) => { + await db.execute( + "UPDATE messages SET selected = 0 WHERE message_set_id = ? AND block_type = 'compare'", + [messageSetId], + ); + }, + onSuccess: async (_data, variables) => { + await queryClient.invalidateQueries({ + queryKey: messageKeys.messageSets(variables.chatId), + }); + + await markProjectContextSummaryAsStale.mutateAsync({ + chatId: variables.chatId, + }); + }, + }); +} + /** * Updates the selected_block_type field in a message set, * and also the current_block_type field in app_metadata @@ -2494,6 +2740,7 @@ function useStreamToolsMessage() { const stopMessageStreaming = useStopMessageStreaming(); const getToolsets = useGetToolsets(); const getProjectContext = useGetProjectContextLLMMessage(); + const { isQuickChatWindow } = useAppContext(); return useMutation({ mutationKey: ["streamToolsMessage"] as const, @@ -2503,12 +2750,16 @@ function useStreamToolsMessage() { messageId, streamingToken, modelConfig, + draftUserInput, + disableTools, }: { chatId: string; messageSetId: string; messageId: string; streamingToken: string; modelConfig: ModelConfig; + draftUserInput?: string; + disableTools?: boolean; }) => { const projectId = ( await queryClient.ensureQueryData(chatQueries.detail(chatId)) @@ -2548,14 +2799,22 @@ function useStreamToolsMessage() { } }, ); - const previousMessageSetsPlusThisMessage = [ - ...previousMessageSets, + const currentMessageConversation = llmConversation([ augmentedLastMessageSet, - ]; - + ]); const conversation: LLMMessage[] = [ ...projectContext, - ...llmConversation(previousMessageSetsPlusThisMessage), + ...llmConversation(previousMessageSets), + ...(draftUserInput + ? [ + { + role: "user" as const, + content: draftUserInput, + attachments: [], + }, + ] + : []), + ...currentMessageConversation, ]; console.log(`[level ${level}] streaming ai message`); @@ -2572,7 +2831,10 @@ function useStreamToolsMessage() { streamingToken, }); - const toolsets = await getToolsets(); + const toolsets = + isQuickChatWindow || disableTools + ? [] + : await getToolsets(); const tools = toolsets.flatMap((toolset) => { return toolset.listTools(); }); @@ -2690,11 +2952,16 @@ function usePopulateToolsBlock(chatId: string) { messageSetId, isQuickChatWindow, replyToModelId, + excludedModelIds, + applyChatCreationModelDefaults, }: { messageSetId: string; previousMessageSets: MessageSetDetail[]; isQuickChatWindow: boolean; replyToModelId?: string; + excludedModelIds?: Set; + /** First user message in a new quick/ambient chat: apply the default ambient model from Defaults settings. Has no effect for regular chats. */ + applyChatCreationModelDefaults?: boolean; }) => { // BTBL: do we need to protect against double-population here by ensuring // it's empty before we populate? @@ -2711,19 +2978,62 @@ function usePopulateToolsBlock(chatId: string) { return { skipped: true }; } modelConfigs = [modelConfig]; + } else if (applyChatCreationModelDefaults && isQuickChatWindow) { + const settings = await SettingsManager.getInstance().get(); + let next = await getSelectedModelConfigs(true, chatId); + if (settings.defaultAmbientChatModel) { + const all = await queryClient.ensureQueryData( + modelConfigQueries.listConfigs(), + ); + const visibilityMap = await buildProviderVisibilityMap(); + const amb = all.find( + (c) => c.id === settings.defaultAmbientChatModel, + ); + if ( + amb && + isModelConfigEffectivelyVisible(amb, visibilityMap) && + modelConfigSupportsVision(amb) + ) { + next = [amb]; + await db.execute( + "INSERT OR REPLACE INTO app_metadata (key, value) VALUES ('quick_chat_model_config_id', ?)", + [amb.id], + ); + queryClient.setQueryData( + modelConfigQueries.quickChat().queryKey, + amb, + ); + } + } + modelConfigs = next; + if (excludedModelIds && excludedModelIds.size > 0) { + modelConfigs = modelConfigs.filter( + (m) => !excludedModelIds.has(m.id), + ); + } } else { - // Normal flow: use selected model configs - modelConfigs = await getSelectedModelConfigs(isQuickChatWindow); + // Normal flow: per-chat compare selection (saved row + ambient fallback) + modelConfigs = await getSelectedModelConfigs( + isQuickChatWindow, + chatId, + ); + // Skip minimized models + if (excludedModelIds && excludedModelIds.size > 0) { + modelConfigs = modelConfigs.filter( + (m) => !excludedModelIds.has(m.id), + ); + } } if (modelConfigs.length === 0) { return { skipped: true }; } - // we do this in two phases so that we can ensure that if the tools block - // contains any message, it always contains a selected message + // Create all tool messages with selected = false by default. + // Note: a DB trigger auto-selects the first inserted message in a set, + // so we explicitly clear selection right after creating the first one. - // phase 1: create the first message (which will be selected) + // phase 1: create the first message const firstModelConfig = modelConfigs[0]; const firstCreateMessageResult = await createMessage.mutateAsync({ message: createAIMessage({ @@ -2731,10 +3041,16 @@ function usePopulateToolsBlock(chatId: string) { messageSetId, blockType: "tools", model: firstModelConfig.id, - selected: true, + selected: false, level: 0, // explicitly set level for first message }), }); + if (firstCreateMessageResult) { + await db.execute( + "UPDATE messages SET selected = 0 WHERE id = ?", + [firstCreateMessageResult.messageId], + ); + } // phase 2: create the rest of the messages and stream all await Promise.all( @@ -2769,6 +3085,11 @@ function usePopulateToolsBlock(chatId: string) { messageId: createMessageResult.messageId, modelConfig, streamingToken: createMessageResult.streamingToken, + disableTools: + toolsDisabledActions.isToolsDisabledForModel( + chatId, + modelConfig.id, + ), }); }), ); @@ -2805,10 +3126,14 @@ export function usePopulateBlock(chatId: string, isQuickChatWindow: boolean) { messageSetId, blockType, replyToModelId, + excludedModelIds, + applyChatCreationModelDefaults, }: { messageSetId: string; blockType: BlockType; replyToModelId?: string; + excludedModelIds?: Set; + applyChatCreationModelDefaults?: boolean; }) => { const messageSets = await getMessageSets(chatId); const messageSet = messageSets.find((m) => m.id === messageSetId); @@ -2834,6 +3159,8 @@ export function usePopulateBlock(chatId: string, isQuickChatWindow: boolean) { previousMessageSets, isQuickChatWindow, replyToModelId, + excludedModelIds, + applyChatCreationModelDefaults, }); } default: { @@ -3088,6 +3415,36 @@ export function useGenerateChatTitle() { const queryClient = useQueryClient(); const getMessageSets = useGetMessageSets(); + const extractTitleFromResponse = (fullResponse: string): string | null => { + if (!fullResponse) return null; + + const tagMatch = fullResponse.match(/(.*?)<\/title>/is); + const rawTitle = tagMatch?.[1] ?? fullResponse; + const withoutTags = rawTitle.replace(/<\/?title>/gi, ""); + const firstLine = + withoutTags + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0) ?? ""; + + const normalized = firstLine + .replace(/^title\s*[:-]\s*/i, "") + .replace(/["']/g, "") + .replace(/\s+/g, " ") + .trim(); + + if (!normalized) return null; + + return normalized.slice(0, 40); + }; + + const fallbackTitleFromMessage = (messageText: string): string | null => { + const normalized = messageText.replace(/\s+/g, " ").trim(); + if (!normalized) return null; + const words = normalized.split(" ").slice(0, 5).join(" "); + return words.slice(0, 40); + }; + return useMutation({ mutationKey: ["generateChatTitle"] as const, mutationFn: async ({ chatId }: { chatId: string }) => { @@ -3105,46 +3462,100 @@ export function useGenerateChatTitle() { } const messageSets = await getMessageSets(chatId); - const userMessageText = Array.from(messageSets) // copy so we can reverse - .reverse() + const userMessageText = messageSets .map((ms) => ms.userBlock?.message?.text) - .find((m) => m !== undefined); + .find((m) => m !== undefined && m.trim().length > 0); if (!userMessageText) { console.log("Skipping title generation for chat", chatId); return { skipped: true }; } - const fullResponse = await simpleLLM( - `Based on this first message, write a 1-5 word title for the conversation. Try to put the most important words first. Format your response as <title>YOUR TITLE HERE. + const settings = await SettingsManager.getInstance().get(); + let titleModelConfigId = settings.titleGenerationModelConfigId; + + // If no explicit title-generation model is set, prefer the ambient/quick-chat model + // (stored in app_metadata, not settings) + if (!titleModelConfigId) { + try { + const quickChatModelConfig = + await queryClient.ensureQueryData( + modelConfigQueries.quickChat(), + ); + if (quickChatModelConfig?.id) { + titleModelConfigId = quickChatModelConfig.id; + } + } catch (e) { + console.warn( + "Failed to resolve quick chat model config for title generation", + e, + ); + } + } + + // As a last resort, fall back to the quickChat model ID from settings + if (!titleModelConfigId) { + titleModelConfigId = settings.quickChat?.modelConfigId; + } + + let cleanTitle: string | null = null; + try { + const fullResponse = await simpleLLM( + `Based on this first message, write a 1-5 word title for the conversation. Try to put the most important words first. Format your response as YOUR TITLE HERE. If there's no information in the message, just return "Untitled Chat". ${userMessageText} `, - { - maxTokens: 100, - }, - ); - // Extract title from XML tags and clean it up - const match = fullResponse.match(/(.*?)<\/title>/s); - if (!match || !match[1]) { - console.warn("No title found in response:", fullResponse); - return; + { + maxTokens: 100, + }, + titleModelConfigId, + ); + cleanTitle = extractTitleFromResponse(fullResponse); + } catch (error) { + console.warn("Failed to generate title via LLM:", error); } - const cleanTitle = match[1] - .trim() - .slice(0, 40) - .replace(/["']/g, ""); - if (cleanTitle) { - console.log("Setting chat title to:", cleanTitle); - await db.execute("UPDATE chats SET title = $1 WHERE id = $2", [ - cleanTitle, - chatId, - ]); + + if (!cleanTitle) { + cleanTitle = fallbackTitleFromMessage(userMessageText); + } + + if (!cleanTitle) { + console.warn("No title found in response or fallback."); + return { skipped: true }; } + + console.log("Setting chat title to:", cleanTitle); + await db.execute("UPDATE chats SET title = $1 WHERE id = $2", [ + cleanTitle, + chatId, + ]); + return { title: cleanTitle }; }, onSuccess: async (data, variables) => { - if (!data?.skipped) { + if (data?.title) { + queryClient.setQueryData( + chatQueries.detail(variables.chatId).queryKey, + (chat: Chat | undefined) => + chat + ? { + ...chat, + title: data.title ?? chat.title, + } + : chat, + ); + queryClient.setQueryData( + chatQueries.list().queryKey, + (chats: Chat[] | undefined) => + chats?.map((chat) => + chat.id === variables.chatId + ? { + ...chat, + title: data.title ?? chat.title, + } + : chat, + ), + ); await queryClient.invalidateQueries(chatQueries.list()); await queryClient.invalidateQueries( chatQueries.detail(variables.chatId), @@ -3294,16 +3705,30 @@ export function useUpdateSelectedModelConfigQuickChat() { export function useGetSelectedModelConfigs() { const queryClient = useQueryClient(); - return async (isQuickChatWindow: boolean) => { + return async (isQuickChatWindow: boolean, chatId: string) => { if (isQuickChatWindow) { const quickChatModelConfig = await queryClient.ensureQueryData( modelConfigQueries.quickChat(), ); return quickChatModelConfig ? [quickChatModelConfig] : []; - } else { - return await queryClient.ensureQueryData( - modelConfigQueries.compare(), - ); } + + const savedIds = await fetchSavedModelConfigChat(chatId); + const allConfigs = await queryClient.ensureQueryData( + modelConfigQueries.listConfigs(), + ); + if (savedIds && savedIds.length > 0) { + const byId = new Map(allConfigs.map((m) => [m.id, m])); + const ordered: ModelConfig[] = []; + for (const id of savedIds) { + const cfg = byId.get(id); + if (cfg) ordered.push(cfg); + } + if (ordered.length > 0) { + return ordered; + } + } + + return await queryClient.ensureQueryData(modelConfigQueries.compare()); }; } diff --git a/src/core/chorus/api/ModelConfigChatAPI.ts b/src/core/chorus/api/ModelConfigChatAPI.ts index df126d6d..ef4d49d4 100644 --- a/src/core/chorus/api/ModelConfigChatAPI.ts +++ b/src/core/chorus/api/ModelConfigChatAPI.ts @@ -1,7 +1,12 @@ // Saved model config hooks import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { getFilteredModelConfigs } from "@core/utilities/ModelFiltering"; +import { resolveOrderedCompareConfigs } from "../ChatCompareSelection"; import { db } from "../DB"; +import * as ModelsAPI from "./ModelsAPI"; +import { useProviderVisibilityMap } from "./ProviderVisibilityAPI"; import { v4 as uuidv4 } from "uuid"; const modelConfigChatKeys = { @@ -9,8 +14,8 @@ const modelConfigChatKeys = { ["savedModelConfig", chatId] as const, }; -// Saved model config functions -async function fetchSavedModelConfigChat( +// Saved model config functions (model **config** ids, same as messages.model) +export async function fetchSavedModelConfigChat( chatId: string, ): Promise<string[] | null> { const rows = await db.select<{ model_ids: string }[]>( @@ -87,6 +92,73 @@ export function useUpdateSavedModelConfigChat() { }); } +/** + * Ordered compare selection for a regular chat: persisted ids ∩ visible configs. + */ +export function useChatCompareModelConfigs(chatId: string) { + const savedModelConfig = useSavedModelConfigChat(chatId); + const ambientCompareQuery = ModelsAPI.useSelectedModelConfigsCompare(); + const modelConfigsQuery = ModelsAPI.useModelConfigs(); + const providerVisibilityMap = useProviderVisibilityMap(); + + const visibleConfigs = useMemo( + () => + getFilteredModelConfigs( + modelConfigsQuery.data ?? [], + providerVisibilityMap, + null, + ), + [modelConfigsQuery.data, providerVisibilityMap], + ); + + return useMemo(() => { + const fromSaved = resolveOrderedCompareConfigs( + savedModelConfig.data, + modelConfigsQuery.data ?? [], + visibleConfigs, + ); + if (fromSaved.length > 0) { + return fromSaved; + } + // If there's an explicit saved record (even empty), don't fall back to ambient. + // null means "no saved data yet" -> use ambient defaults. + if (savedModelConfig.data != null) { + return []; + } + const visibleIds = new Set(visibleConfigs.map((c) => c.id)); + return (ambientCompareQuery.data ?? []).filter((m) => + visibleIds.has(m.id), + ); + }, [ + savedModelConfig.data, + ambientCompareQuery.data, + modelConfigsQuery.data, + visibleConfigs, + ]); +} + +export function useAppendModelConfigToChatCompare(chatId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (newSelectedModelConfigId: string) => { + const current = (await fetchSavedModelConfigChat(chatId)) ?? []; + if (current.includes(newSelectedModelConfigId)) { + return; + } + await updateSavedModelConfigChat(chatId, [ + ...current, + newSelectedModelConfigId, + ]); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: modelConfigChatKeys.savedModelConfigChat(chatId), + }); + }, + }); +} + // Convenience hook for reply chats - gets just the first model ID export function useReplyModelConfig(chatId: string) { const savedModelConfig = useSavedModelConfigChat(chatId); @@ -96,18 +168,21 @@ export function useReplyModelConfig(chatId: string) { }; } -// Convenience hook for updating reply model - updates with a single model ID +// Convenience hook for updating reply model (one model **config** id) export function useUpdateReplyModelConfig() { const updateSavedModelConfig = useUpdateSavedModelConfigChat(); return useMutation({ mutationFn: ({ chatId, - modelId, + modelConfigId, }: { chatId: string; - modelId: string; + modelConfigId: string; }) => - updateSavedModelConfig.mutateAsync({ chatId, modelIds: [modelId] }), + updateSavedModelConfig.mutateAsync({ + chatId, + modelIds: [modelConfigId], + }), }); } diff --git a/src/core/chorus/api/ModelProfilesAPI.ts b/src/core/chorus/api/ModelProfilesAPI.ts new file mode 100644 index 00000000..dedc4db3 --- /dev/null +++ b/src/core/chorus/api/ModelProfilesAPI.ts @@ -0,0 +1,183 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { db } from "../DB"; +import { ModelProfile } from "../Models"; + +const modelProfileKeys = { + all: () => ["modelProfiles"] as const, + list: () => [...modelProfileKeys.all(), "list"] as const, + active: () => [...modelProfileKeys.all(), "active"] as const, +}; + +type ModelProfileDBRow = { + id: string; + name: string; + model_config_ids: string; + created_at: string; + updated_at: string; +}; + +function readModelProfile(row: ModelProfileDBRow): ModelProfile { + return { + id: row.id, + name: row.name, + modelConfigIds: JSON.parse(row.model_config_ids) as string[], + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +/** + * Fetch all model profiles from the database. + */ +export async function fetchModelProfiles(): Promise<ModelProfile[]> { + const rows = await db.select<ModelProfileDBRow[]>( + "SELECT id, name, model_config_ids, created_at, updated_at FROM model_profiles ORDER BY created_at ASC", + ); + return rows.map(readModelProfile); +} + +/** + * Fetch the active model profile ID from app_metadata. + */ +export async function fetchActiveModelProfileId(): Promise<string | null> { + const rows = await db.select<{ value: string }[]>( + "SELECT value FROM app_metadata WHERE key = 'active_model_profile_id'", + ); + return rows.length > 0 ? rows[0].value : null; +} + +/** + * Hook to get all model profiles. + */ +export function useModelProfiles() { + return useQuery({ + queryKey: modelProfileKeys.list(), + queryFn: fetchModelProfiles, + }); +} + +/** + * Hook to get the active model profile ID. + */ +export function useActiveModelProfileId() { + return useQuery({ + queryKey: modelProfileKeys.active(), + queryFn: fetchActiveModelProfileId, + }); +} + +/** + * Hook to get the full active model profile (if any). + */ +export function useActiveModelProfile(): ModelProfile | null { + const { data: profiles } = useModelProfiles(); + const { data: activeId } = useActiveModelProfileId(); + + if (!profiles || !activeId) return null; + + return profiles.find((p) => p.id === activeId) ?? null; +} + +/** + * Hook to set the active model profile. + * Pass null to deactivate (no profile active). + */ +export function useSetActiveModelProfile() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["setActiveModelProfile"] as const, + mutationFn: async (profileId: string | null) => { + if (profileId) { + await db.execute( + "INSERT OR REPLACE INTO app_metadata (key, value) VALUES ('active_model_profile_id', ?)", + [profileId], + ); + } else { + await db.execute( + "DELETE FROM app_metadata WHERE key = 'active_model_profile_id'", + ); + } + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: modelProfileKeys.active(), + }); + }, + }); +} + +/** + * Hook to create a new model profile. + */ +export function useCreateModelProfile() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["createModelProfile"] as const, + mutationFn: async ({ + id, + name, + modelConfigIds, + }: { + id: string; + name: string; + modelConfigIds: string[]; + }) => { + await db.execute( + "INSERT INTO model_profiles (id, name, model_config_ids) VALUES (?, ?, ?)", + [id, name, JSON.stringify(modelConfigIds)], + ); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: modelProfileKeys.list(), + }); + }, + }); +} + +/** + * Hook to update an existing model profile. + */ +export function useUpdateModelProfile() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["updateModelProfile"] as const, + mutationFn: async ({ + id, + name, + modelConfigIds, + }: { + id: string; + name: string; + modelConfigIds: string[]; + }) => { + await db.execute( + "UPDATE model_profiles SET name = ?, model_config_ids = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + [name, JSON.stringify(modelConfigIds), id], + ); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: modelProfileKeys.list(), + }); + }, + }); +} + +/** + * Hook to delete a model profile. + */ +export function useDeleteModelProfile() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["deleteModelProfile"] as const, + mutationFn: async ({ id }: { id: string }) => { + await db.execute("DELETE FROM model_profiles WHERE id = ?", [id]); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: modelProfileKeys.list(), + }); + }, + }); +} diff --git a/src/core/chorus/api/ProjectAPI.ts b/src/core/chorus/api/ProjectAPI.ts index fd2f2830..2544e96e 100644 --- a/src/core/chorus/api/ProjectAPI.ts +++ b/src/core/chorus/api/ProjectAPI.ts @@ -45,6 +45,8 @@ export type Project = { isImported: boolean; // Cost tracking totalCostUsd?: number; + /** Per-project default prompt profile; overrides the global default when set. */ + defaultPromptProfileId?: string; }; export type Projects = { @@ -63,6 +65,7 @@ type ProjectDBRow = { context_text?: string; is_imported: number; total_cost_usd: number | null; + default_prompt_profile_id: string | null; }; function readProject(row: ProjectDBRow): Project { @@ -76,13 +79,14 @@ function readProject(row: ProjectDBRow): Project { magicProjectsEnabled: row.magic_projects_enabled === 1, isImported: row.is_imported === 1, totalCostUsd: row.total_cost_usd ?? undefined, + defaultPromptProfileId: row.default_prompt_profile_id ?? undefined, }; } export async function fetchProjects(): Promise<Project[]> { return await db .select<ProjectDBRow[]>( - `SELECT id, name, updated_at, created_at, is_collapsed, magic_projects_enabled, is_imported, total_cost_usd + `SELECT id, name, updated_at, created_at, is_collapsed, magic_projects_enabled, is_imported, total_cost_usd, default_prompt_profile_id FROM projects ORDER BY updated_at DESC`, ) @@ -115,7 +119,7 @@ export async function fetchProjectContextAttachments( export async function fetchProject(projectId: string) { const rows = await db.select<ProjectDBRow[]>( - "SELECT * FROM projects WHERE id = ?", + "SELECT id, name, updated_at, created_at, is_collapsed, magic_projects_enabled, context_text, is_imported, total_cost_usd, default_prompt_profile_id FROM projects WHERE id = ?", [projectId], ); if (rows.length === 0) { @@ -641,6 +645,31 @@ export function useFinalizeAttachmentForProject() { }); } +export function useSetProjectDefaultPromptProfile() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["setProjectDefaultPromptProfile"] as const, + mutationFn: async ({ + projectId, + profileId, + }: { + projectId: string; + profileId: string | null; + }) => { + await db.execute( + "UPDATE projects SET default_prompt_profile_id = ? WHERE id = ?", + [profileId, projectId], + ); + }, + onSuccess: async (_data, variables) => { + await queryClient.invalidateQueries(projectQueries.list()); + await queryClient.invalidateQueries( + projectQueries.detail(variables.projectId), + ); + }, + }); +} + export function useToggleProjectIsCollapsed() { const queryClient = useQueryClient(); return useMutation({ diff --git a/src/core/chorus/api/PromptProfilesAPI.ts b/src/core/chorus/api/PromptProfilesAPI.ts new file mode 100644 index 00000000..109ae129 --- /dev/null +++ b/src/core/chorus/api/PromptProfilesAPI.ts @@ -0,0 +1,201 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { db } from "../DB"; +import { PromptProfile } from "../Models"; +import { v4 as uuidv4 } from "uuid"; + +const promptProfileKeys = { + all: () => ["promptProfiles"] as const, + list: () => [...promptProfileKeys.all(), "list"] as const, + chatProfile: (chatId: string) => + [...promptProfileKeys.all(), "chat", chatId] as const, +}; + +type PromptProfileDBRow = { + id: string; + name: string; + system_prompt: string; + icon: string | null; + author: "user" | "system"; + created_at: string; + updated_at: string; +}; + +function readPromptProfile(row: PromptProfileDBRow): PromptProfile { + return { + id: row.id, + name: row.name, + systemPrompt: row.system_prompt, + icon: row.icon ?? undefined, + author: row.author, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export async function fetchPromptProfiles(): Promise<PromptProfile[]> { + const rows = await db.select<PromptProfileDBRow[]>( + "SELECT id, name, system_prompt, icon, author, created_at, updated_at FROM prompt_profiles ORDER BY created_at ASC", + ); + return rows.map(readPromptProfile); +} + +/** + * Fetch the system prompt for the profile associated with a chat. + * Returns undefined if no profile is set. + * Intended for use inside mutations (not a hook). + */ +export async function fetchChatPromptProfileSystemPrompt( + chatId: string, +): Promise<string | undefined> { + const rows = await db.select<{ system_prompt: string }[]>( + `SELECT pp.system_prompt + FROM prompt_profile_chats ppc + JOIN prompt_profiles pp ON pp.id = ppc.prompt_profile_id + WHERE ppc.chat_id = ?`, + [chatId], + ); + return rows.length > 0 ? rows[0].system_prompt : undefined; +} + +/** + * Fetch the prompt profile ID associated with a chat. + */ +async function fetchChatPromptProfileId( + chatId: string, +): Promise<string | null> { + const rows = await db.select<{ prompt_profile_id: string }[]>( + "SELECT prompt_profile_id FROM prompt_profile_chats WHERE chat_id = ?", + [chatId], + ); + return rows.length > 0 ? rows[0].prompt_profile_id : null; +} + +export function usePromptProfiles() { + return useQuery({ + queryKey: promptProfileKeys.list(), + queryFn: fetchPromptProfiles, + }); +} + +export function useChatPromptProfileId(chatId: string) { + return useQuery({ + queryKey: promptProfileKeys.chatProfile(chatId), + queryFn: () => fetchChatPromptProfileId(chatId), + }); +} + +/** + * Returns the full PromptProfile for a chat, or undefined if none is set. + */ +export function useChatPromptProfile( + chatId: string, +): PromptProfile | undefined { + const { data: profiles } = usePromptProfiles(); + const { data: profileId } = useChatPromptProfileId(chatId); + if (!profiles || !profileId) return undefined; + return profiles.find((p) => p.id === profileId); +} + +/** + * Set or clear the prompt profile for a chat. + * Pass null to remove the association. + */ +export function useSetChatPromptProfile() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + chatId, + profileId, + }: { + chatId: string; + profileId: string | null; + }) => { + if (profileId) { + await db.execute( + "INSERT OR REPLACE INTO prompt_profile_chats (id, chat_id, prompt_profile_id) VALUES (?, ?, ?)", + [uuidv4(), chatId, profileId], + ); + } else { + await db.execute( + "DELETE FROM prompt_profile_chats WHERE chat_id = ?", + [chatId], + ); + } + }, + onSuccess: async (_data, variables) => { + await queryClient.invalidateQueries({ + queryKey: promptProfileKeys.chatProfile(variables.chatId), + }); + }, + }); +} + +export function useCreatePromptProfile() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + name, + systemPrompt, + icon, + }: { + name: string; + systemPrompt: string; + icon?: string; + }) => { + await db.execute( + "INSERT INTO prompt_profiles (id, name, system_prompt, icon, author) VALUES (?, ?, ?, ?, 'user')", + [uuidv4(), name, systemPrompt, icon ?? null], + ); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: promptProfileKeys.list(), + }); + }, + }); +} + +export function useUpdatePromptProfile() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + id, + name, + systemPrompt, + icon, + }: { + id: string; + name: string; + systemPrompt: string; + icon?: string; + }) => { + await db.execute( + "UPDATE prompt_profiles SET name = ?, system_prompt = ?, icon = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + [name, systemPrompt, icon ?? null, id], + ); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: promptProfileKeys.list(), + }); + }, + }); +} + +export function useDeletePromptProfile() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ id }: { id: string }) => { + await db.execute( + "DELETE FROM prompt_profile_chats WHERE prompt_profile_id = ?", + [id], + ); + await db.execute("DELETE FROM prompt_profiles WHERE id = ?", [id]); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: promptProfileKeys.all(), + }); + }, + }); +} diff --git a/src/core/chorus/api/ProviderVisibilityAPI.ts b/src/core/chorus/api/ProviderVisibilityAPI.ts new file mode 100644 index 00000000..9d9b90e4 --- /dev/null +++ b/src/core/chorus/api/ProviderVisibilityAPI.ts @@ -0,0 +1,119 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { db } from "../DB"; +import { ProviderVisibility, ProviderName } from "../Models"; + +const providerVisibilityKeys = { + all: () => ["providerVisibility"] as const, + list: () => [...providerVisibilityKeys.all(), "list"] as const, +}; + +type ProviderVisibilityDBRow = { + provider_name: string; + model_id: string; + is_visible: number; +}; + +function readProviderVisibility( + row: ProviderVisibilityDBRow, +): ProviderVisibility { + return { + providerName: row.provider_name, + modelId: row.model_id, + isVisible: row.is_visible === 1, + }; +} + +/** + * Fetch all provider visibility records from the database. + */ +export async function fetchProviderVisibleModels(): Promise< + ProviderVisibility[] +> { + const rows = await db.select<ProviderVisibilityDBRow[]>( + "SELECT provider_name, model_id, is_visible FROM provider_visible_models", + ); + return rows.map(readProviderVisibility); +} + +/** + * Hook to get all provider visibility records. + */ +export function useProviderVisibleModels() { + return useQuery({ + queryKey: providerVisibilityKeys.list(), + queryFn: fetchProviderVisibleModels, + }); +} + +/** + * Hook to set visibility for a specific model. + */ +export function useSetModelVisibility() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["setModelVisibility"] as const, + mutationFn: async ({ + providerName, + modelId, + isVisible, + }: { + providerName: string; + modelId: string; + isVisible: boolean; + }) => { + await db.execute( + "INSERT OR REPLACE INTO provider_visible_models (provider_name, model_id, is_visible) VALUES (?, ?, ?)", + [providerName, modelId, isVisible ? 1 : 0], + ); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: providerVisibilityKeys.list(), + }); + }, + }); +} + +/** + * Hook to set visibility for all models of a provider at once. + */ +export function useSetAllProviderModelsVisible() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["setAllProviderModelsVisible"] as const, + mutationFn: async ({ + providerName, + modelIds, + isVisible, + }: { + providerName: ProviderName; + modelIds: string[]; + isVisible: boolean; + }) => { + const value = isVisible ? 1 : 0; + for (const modelId of modelIds) { + await db.execute( + "INSERT OR REPLACE INTO provider_visible_models (provider_name, model_id, is_visible) VALUES (?, ?, ?)", + [providerName, modelId, value], + ); + } + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: providerVisibilityKeys.list(), + }); + }, + }); +} + +/** + * Get the visibility map for quick lookup. + * Returns a Map where key is modelId and value is isVisible. + * Models not in the map should be considered visible by default. + */ +export function useProviderVisibilityMap(): Map<string, boolean> | undefined { + const { data } = useProviderVisibleModels(); + if (!data) return undefined; + + return new Map(data.map((v) => [v.modelId, v.isVisible])); +} diff --git a/src/core/chorus/chatCreationDefaults.ts b/src/core/chorus/chatCreationDefaults.ts new file mode 100644 index 00000000..63fe0f9d --- /dev/null +++ b/src/core/chorus/chatCreationDefaults.ts @@ -0,0 +1,135 @@ +import { v4 as uuidv4 } from "uuid"; +import { db } from "./DB"; +import { ModelConfig } from "./Models"; +import type { Settings } from "@core/utilities/Settings"; +import { SettingsManager } from "@core/utilities/Settings"; +import { fetchProviderVisibleModels } from "./api/ProviderVisibilityAPI"; +import { fetchModelConfigs, modelConfigQueries } from "./api/ModelsAPI"; +import { fetchPromptProfiles } from "./api/PromptProfilesAPI"; +import { fetchProject } from "./api/ProjectAPI"; +import type { QueryClient } from "@tanstack/react-query"; + +/** + * Whether this model config counts as visible in pickers (Visible Models + enabled). + */ +export function isModelConfigEffectivelyVisible( + config: ModelConfig, + visibilityMap: Map<string, boolean>, +): boolean { + const v = visibilityMap.get(config.modelId); + const visible = v === undefined ? true : v; + return ( + visible && + config.isEnabled && + !config.isInternal && + !config.isDeprecated + ); +} + +export function modelConfigSupportsVision(config: ModelConfig): boolean { + return config.supportedAttachmentTypes.includes("image"); +} + +export async function buildProviderVisibilityMap(): Promise< + Map<string, boolean> +> { + const rows = await fetchProviderVisibleModels(); + return new Map(rows.map((r) => [r.modelId, r.isVisible])); +} + +/** + * After a chat row is inserted: prompt profile (regular) or ambient model metadata (quick). + */ +export async function applyCreationDefaultsForNewChatRow( + chatId: string, + queryClient?: QueryClient, +): Promise<void> { + const rows = await db.select<{ quick_chat: number; project_id: string }[]>( + "SELECT quick_chat, project_id FROM chats WHERE id = ?", + [chatId], + ); + if (rows.length === 0) return; + + const settings = await SettingsManager.getInstance().get(); + + if (rows[0].quick_chat === 1) { + await applyDefaultAmbientModelToMetadata(settings, queryClient); + return; + } + + // Use per-project default profile if set, falling back to global default. + let projectDefaultProfileId: string | undefined; + const projectId = rows[0].project_id; + if (projectId && projectId !== "default") { + try { + const project = await fetchProject(projectId); + projectDefaultProfileId = project.defaultPromptProfileId; + } catch { + // project not found; proceed with global default + } + } + + await applyDefaultPromptProfileForChat( + chatId, + settings, + projectDefaultProfileId, + ); +} + +export async function applyDefaultPromptProfileForChat( + chatId: string, + settings?: Settings, + projectDefaultProfileId?: string, +): Promise<void> { + const s = settings ?? (await SettingsManager.getInstance().get()); + // Per-project profile takes precedence over the global default. + const profileId = projectDefaultProfileId ?? s.defaultPromptProfileId; + if (!profileId) return; + + const profiles = await fetchPromptProfiles(); + if (!profiles.some((p) => p.id === profileId)) return; + + const existing = await db.select<{ id: string }[]>( + "SELECT id FROM prompt_profile_chats WHERE chat_id = ?", + [chatId], + ); + if (existing.length > 0) return; + + await db.execute( + "INSERT INTO prompt_profile_chats (id, chat_id, prompt_profile_id) VALUES (?, ?, ?)", + [uuidv4(), chatId, profileId], + ); +} + +export async function applyDefaultAmbientModelToMetadata( + settings?: Settings, + queryClient?: QueryClient, +): Promise<void> { + const s = settings ?? (await SettingsManager.getInstance().get()); + const id = s.defaultAmbientChatModel; + if (!id) return; + + const all = await fetchModelConfigs(); + const visibilityMap = await buildProviderVisibilityMap(); + const config = all.find((c) => c.id === id); + if ( + !config || + !isModelConfigEffectivelyVisible(config, visibilityMap) || + !modelConfigSupportsVision(config) + ) { + return; + } + + await db.execute( + "INSERT OR REPLACE INTO app_metadata (key, value) VALUES ('quick_chat_model_config_id', ?)", + [config.id], + ); + + if (queryClient) { + queryClient.setQueryData( + modelConfigQueries.quickChat().queryKey, + config, + ); + await queryClient.invalidateQueries(modelConfigQueries.quickChat()); + } +} diff --git a/src/core/chorus/prompts/prompts.ts b/src/core/chorus/prompts/prompts.ts index 0c6c0347..8f8ec293 100644 --- a/src/core/chorus/prompts/prompts.ts +++ b/src/core/chorus/prompts/prompts.ts @@ -604,9 +604,15 @@ export function injectSystemPrompts( }[]; isInProject?: boolean; universalSystemPrompt?: string; + promptProfileSystemPrompt?: string; }, ): ModelConfig { - const { toolsetInfo, isInProject, universalSystemPrompt } = options ?? { + const { + toolsetInfo, + isInProject, + universalSystemPrompt, + promptProfileSystemPrompt, + } = options ?? { isInProject: false, }; @@ -615,6 +621,7 @@ export function injectSystemPrompts( systemPrompt: [ CHORUS_SYSTEM_PROMPT, universalSystemPrompt || UNIVERSAL_SYSTEM_PROMPT_DEFAULT, + ...(promptProfileSystemPrompt ? [promptProfileSystemPrompt] : []), ...(toolsetInfo ? [TOOLS_MODE_SYSTEM_PROMPT(toolsetInfo)] : []), ...(isInProject ? [PROJECTS_SYSTEM_PROMPT] : []), ...(modelConfigIn.systemPrompt diff --git a/src/core/chorus/simpleLLM.ts b/src/core/chorus/simpleLLM.ts index c9591df4..04a69f05 100644 --- a/src/core/chorus/simpleLLM.ts +++ b/src/core/chorus/simpleLLM.ts @@ -1,22 +1,71 @@ import { SettingsManager } from "@core/utilities/Settings"; -import { getSimpleCompletionProvider } from "./ModelProviders/simple/SimpleCompletionProviderFactory"; +import { + getSimpleCompletionProvider, + createProviderByPrefix, +} from "./ModelProviders/simple/SimpleCompletionProviderFactory"; import { SimpleCompletionParams, SimpleCompletionMode, } from "./ModelProviders/simple/ISimpleCompletionProvider"; +import { fetchModelConfigById } from "./api/ModelsAPI"; +import { ApiKeys } from "./Models"; + +/** + * Resolves a model config ID to a provider + model name string. + * Returns null if the config is missing, the provider unknown, or the API key absent. + */ +async function resolveProviderForModelConfig( + modelConfigId: string, + apiKeys: ApiKeys, +): Promise<{ + provider: NonNullable<ReturnType<typeof createProviderByPrefix>>; + modelName: string; +} | null> { + const modelConfig = await fetchModelConfigById(modelConfigId); + if (!modelConfig) return null; + + const separatorIdx = modelConfig.modelId.indexOf("::"); + if (separatorIdx === -1) return null; + + const providerPrefix = modelConfig.modelId.slice(0, separatorIdx); + const modelName = modelConfig.modelId.slice(separatorIdx + 2); + const provider = createProviderByPrefix(providerPrefix, apiKeys); + if (!provider) return null; + + return { provider, modelName }; +} /** * Makes a simple LLM call using the first available provider. * Used primarily for generating chat titles and suggestions. + * + * @param prompt The prompt to send + * @param params Completion params (maxTokens, optional model) + * @param modelConfigId Optional model config ID to use a specific model instead of auto-selecting */ export async function simpleLLM( prompt: string, params: SimpleCompletionParams, + modelConfigId?: string, ): Promise<string> { const settingsManager = SettingsManager.getInstance(); const settings = await settingsManager.get(); const apiKeys = settings.apiKeys || {}; + if (modelConfigId) { + const resolved = await resolveProviderForModelConfig( + modelConfigId, + apiKeys, + ); + if (resolved) { + return resolved.provider.complete(prompt, { + ...params, + model: resolved.modelName, + }); + } + // Fall through to default behavior if resolution fails + } + // Default to title generation mode if no model specified const paramsWithMode: SimpleCompletionParams = { ...params, diff --git a/src/core/infra/MinimizedModelsStore.ts b/src/core/infra/MinimizedModelsStore.ts new file mode 100644 index 00000000..ac1f3e0e --- /dev/null +++ b/src/core/infra/MinimizedModelsStore.ts @@ -0,0 +1,56 @@ +import { create } from "zustand"; + +interface MinimizedModelsStore { + minimizedModelsByChatId: Map<string, Set<string>>; + minimizeModel: (chatId: string, modelId: string) => void; + expandModel: (chatId: string, modelId: string) => void; + clearChat: (chatId: string) => void; +} + +const useMinimizedModelsStore = create<MinimizedModelsStore>((set) => ({ + minimizedModelsByChatId: new Map(), + + minimizeModel: (chatId, modelId) => + set((state) => { + const current = + state.minimizedModelsByChatId.get(chatId) ?? new Set<string>(); + if (current.has(modelId)) return state; + const next = new Map(state.minimizedModelsByChatId); + next.set(chatId, new Set([...current, modelId])); + return { minimizedModelsByChatId: next }; + }), + + expandModel: (chatId, modelId) => + set((state) => { + const current = state.minimizedModelsByChatId.get(chatId); + if (!current?.has(modelId)) return state; + const nextSet = new Set(current); + nextSet.delete(modelId); + const next = new Map(state.minimizedModelsByChatId); + if (nextSet.size === 0) { + next.delete(chatId); + } else { + next.set(chatId, nextSet); + } + return { minimizedModelsByChatId: next }; + }), + + clearChat: (chatId) => + set((state) => { + if (!state.minimizedModelsByChatId.has(chatId)) return state; + const next = new Map(state.minimizedModelsByChatId); + next.delete(chatId); + return { minimizedModelsByChatId: next }; + }), +})); + +export const minimizedModelsActions = { + minimizeModel: (chatId: string, modelId: string) => + useMinimizedModelsStore.getState().minimizeModel(chatId, modelId), + expandModel: (chatId: string, modelId: string) => + useMinimizedModelsStore.getState().expandModel(chatId, modelId), + clearChat: (chatId: string) => + useMinimizedModelsStore.getState().clearChat(chatId), +}; + +export { useMinimizedModelsStore }; diff --git a/src/core/infra/ModelOrderStore.ts b/src/core/infra/ModelOrderStore.ts new file mode 100644 index 00000000..6e6a59b7 --- /dev/null +++ b/src/core/infra/ModelOrderStore.ts @@ -0,0 +1,57 @@ +import { create } from "zustand"; + +interface ModelOrderStore { + modelOrderByChatId: Map<string, string[]>; + setModelOrder: (chatId: string, modelIds: string[]) => void; + getModelOrder: (chatId: string) => string[] | undefined; + clearChat: (chatId: string) => void; + // The resolved visual order (after applying finish-time sorting, custom order, etc.) + // Written by ToolsBlockView so keybinding handlers can use the correct visual index. + currentVisualOrderByChatId: Map<string, string[]>; + setCurrentVisualOrder: (chatId: string, modelIds: string[]) => void; +} + +const useModelOrderStore = create<ModelOrderStore>((set, get) => ({ + modelOrderByChatId: new Map(), + + setModelOrder: (chatId, modelIds) => + set((state) => { + const next = new Map(state.modelOrderByChatId); + next.set(chatId, modelIds); + return { modelOrderByChatId: next }; + }), + + getModelOrder: (chatId) => get().modelOrderByChatId.get(chatId), + + clearChat: (chatId) => + set((state) => { + const nextModelOrder = new Map(state.modelOrderByChatId); + nextModelOrder.delete(chatId); + const nextVisualOrder = new Map(state.currentVisualOrderByChatId); + nextVisualOrder.delete(chatId); + return { + modelOrderByChatId: nextModelOrder, + currentVisualOrderByChatId: nextVisualOrder, + }; + }), + + currentVisualOrderByChatId: new Map(), + + setCurrentVisualOrder: (chatId, modelIds) => + set((state) => { + const next = new Map(state.currentVisualOrderByChatId); + next.set(chatId, modelIds); + return { currentVisualOrderByChatId: next }; + }), +})); + +export const modelOrderActions = { + setModelOrder: (chatId: string, modelIds: string[]) => + useModelOrderStore.getState().setModelOrder(chatId, modelIds), + getModelOrder: (chatId: string) => + useModelOrderStore.getState().getModelOrder(chatId), + clearChat: (chatId: string) => + useModelOrderStore.getState().clearChat(chatId), +}; + +export { useModelOrderStore }; diff --git a/src/core/infra/ToolsDisabledStore.ts b/src/core/infra/ToolsDisabledStore.ts new file mode 100644 index 00000000..69380e9b --- /dev/null +++ b/src/core/infra/ToolsDisabledStore.ts @@ -0,0 +1,63 @@ +import { create } from "zustand"; + +interface ToolsDisabledStore { + toolsDisabledByChatId: Map<string, Set<string>>; + disableToolsForModel: (chatId: string, modelId: string) => void; + enableToolsForModel: (chatId: string, modelId: string) => void; + clearChat: (chatId: string) => void; +} + +const useToolsDisabledStore = create<ToolsDisabledStore>((set) => ({ + toolsDisabledByChatId: new Map(), + + disableToolsForModel: (chatId, modelId) => + set((state) => { + const current = + state.toolsDisabledByChatId.get(chatId) ?? new Set<string>(); + if (current.has(modelId)) return state; + + const next = new Map(state.toolsDisabledByChatId); + next.set(chatId, new Set([...current, modelId])); + return { toolsDisabledByChatId: next }; + }), + + enableToolsForModel: (chatId, modelId) => + set((state) => { + const current = state.toolsDisabledByChatId.get(chatId); + if (!current?.has(modelId)) return state; + + const nextSet = new Set(current); + nextSet.delete(modelId); + const next = new Map(state.toolsDisabledByChatId); + if (nextSet.size === 0) { + next.delete(chatId); + } else { + next.set(chatId, nextSet); + } + return { toolsDisabledByChatId: next }; + }), + + clearChat: (chatId) => + set((state) => { + if (!state.toolsDisabledByChatId.has(chatId)) return state; + const next = new Map(state.toolsDisabledByChatId); + next.delete(chatId); + return { toolsDisabledByChatId: next }; + }), +})); + +export const toolsDisabledActions = { + disableToolsForModel: (chatId: string, modelId: string) => + useToolsDisabledStore.getState().disableToolsForModel(chatId, modelId), + enableToolsForModel: (chatId: string, modelId: string) => + useToolsDisabledStore.getState().enableToolsForModel(chatId, modelId), + clearChat: (chatId: string) => + useToolsDisabledStore.getState().clearChat(chatId), + isToolsDisabledForModel: (chatId: string, modelId: string) => + useToolsDisabledStore + .getState() + .toolsDisabledByChatId.get(chatId) + ?.has(modelId) ?? false, +}; + +export { useToolsDisabledStore }; diff --git a/src/core/utilities/ChorusDefaultPreferences.ts b/src/core/utilities/ChorusDefaultPreferences.ts new file mode 100644 index 00000000..c7115546 --- /dev/null +++ b/src/core/utilities/ChorusDefaultPreferences.ts @@ -0,0 +1,36 @@ +import type { Settings } from "./Settings"; + +/** Direct Google system config (migrations seed this id). */ +export const CHORUS_DEFAULT_GOOGLE_GEMINI_25_FLASH_LITE = + "google::gemini-2.5-flash-lite"; + +/** + * OpenRouter catalog id for the same model class (after `downloadOpenRouterModels`). + * Filtered out of defaults until the model exists and is visible. + */ +export const CHORUS_DEFAULT_OPENROUTER_GEMINI_25_FLASH_LITE = + "openrouter::google/gemini-2.5-flash-lite"; + +/** + * Fresh-install defaults for model-related settings + prompt profile. + * Always seeds Google Flash Lite as the ambient, fallback, and default chat + * model, since no API keys are present at first install. Users can update + * their defaults in Settings > Defaults after adding keys. + */ +export function buildFreshInstallModelAndPromptDefaults(): Pick< + Settings, + | "defaultPromptProfileId" + | "defaultFallbackModelProfileId" + | "defaultAmbientChatModel" + | "defaultFallbackModel" + | "defaultChatModels" +> & { quickChatModelConfigId: string } { + return { + defaultPromptProfileId: null, + defaultFallbackModelProfileId: null, + defaultAmbientChatModel: CHORUS_DEFAULT_GOOGLE_GEMINI_25_FLASH_LITE, + defaultFallbackModel: CHORUS_DEFAULT_GOOGLE_GEMINI_25_FLASH_LITE, + defaultChatModels: [CHORUS_DEFAULT_GOOGLE_GEMINI_25_FLASH_LITE], + quickChatModelConfigId: CHORUS_DEFAULT_GOOGLE_GEMINI_25_FLASH_LITE, + }; +} diff --git a/src/core/utilities/ModelFiltering.ts b/src/core/utilities/ModelFiltering.ts new file mode 100644 index 00000000..ff729e51 --- /dev/null +++ b/src/core/utilities/ModelFiltering.ts @@ -0,0 +1,77 @@ +import { ModelConfig, ModelProfile } from "@core/chorus/Models"; + +/** + * Centralized filtering utility that combines provider visibility and profile filtering. + * + * Filtering order: + * 1. Filter out internal and deprecated models (always) + * 2. Filter by provider visibility (if configured) + * 3. Filter by active profile (if active) + * + * Models not in the visibility map are considered visible by default (backward compatibility). + * + * @param allModelConfigs - All available model configs + * @param providerVisibilityMap - Map of modelId -> isVisible (from ProviderVisibilityAPI) + * @param activeProfile - Currently active profile (or null) + * @returns Filtered model configs that pass all filters + */ +export function getFilteredModelConfigs( + allModelConfigs: ModelConfig[], + providerVisibilityMap: Map<string, boolean> | undefined, + activeProfile: ModelProfile | null, +): ModelConfig[] { + // Step 1: Always filter out internal and deprecated models + let filtered = allModelConfigs.filter( + (config) => + !config.isInternal && !config.isDeprecated && config.isEnabled, + ); + + // Step 2: Filter by provider visibility + // If providerVisibilityMap is undefined, assume all models visible (backward compatibility) + if (providerVisibilityMap && providerVisibilityMap.size > 0) { + filtered = filtered.filter((config) => { + const isVisible = providerVisibilityMap.get(config.modelId); + // If model is not in the visibility map, default to visible + return isVisible === undefined ? true : isVisible; + }); + } + + // Step 3: If a profile is active, filter to only include models in that profile + // Note: Profile uses modelConfigIds, not modelIds + if (activeProfile) { + const profileConfigIds = new Set(activeProfile.modelConfigIds); + filtered = filtered.filter((config) => profileConfigIds.has(config.id)); + } + + return filtered; +} + +/** + * Get the list of provider names that have at least one model in the given configs. + */ +export function getProvidersWithModels(modelConfigs: ModelConfig[]): string[] { + const providers = new Set<string>(); + for (const config of modelConfigs) { + const provider = config.modelId.split("::")[0]; + if (provider) { + providers.add(provider); + } + } + return Array.from(providers).sort(); +} + +/** + * Group model configs by provider. + */ +export function groupModelsByProvider( + modelConfigs: ModelConfig[], +): Map<string, ModelConfig[]> { + const groups = new Map<string, ModelConfig[]>(); + for (const config of modelConfigs) { + const provider = config.modelId.split("::")[0] ?? "unknown"; + const existing = groups.get(provider) ?? []; + existing.push(config); + groups.set(provider, existing); + } + return groups; +} diff --git a/src/core/utilities/Settings.ts b/src/core/utilities/Settings.ts index f6114dcc..60e33079 100644 --- a/src/core/utilities/Settings.ts +++ b/src/core/utilities/Settings.ts @@ -1,5 +1,6 @@ import { getStore } from "@core/infra/Store"; import { emit } from "@tauri-apps/api/event"; +import { buildFreshInstallModelAndPromptDefaults } from "./ChorusDefaultPreferences"; export interface Settings { defaultEditor: string; @@ -23,6 +24,17 @@ export interface Settings { }; lmStudioBaseUrl?: string; cautiousEnter?: boolean; + titleGenerationModelConfigId?: string; + /** Model config ids for new regular chats; null/undefined = use ambient compare list */ + defaultChatModels?: string[] | null; + /** Default prompt profile for new regular chats */ + defaultPromptProfileId?: string | null; + /** Single fallback model config when no other selection applies. */ + defaultFallbackModel?: string | null; + /** Optional model profile whose allowed configs must include the fallback model config id. */ + defaultFallbackModelProfileId?: string | null; + /** Vision-capable model config for ambient / quick chat. */ + defaultAmbientChatModel?: string | null; } export class SettingsManager { @@ -42,7 +54,9 @@ export class SettingsManager { try { const store = await getStore(this.storeName); const settings = await store.get("settings"); - const defaultSettings = { + const { quickChatModelConfigId, ...modelPreferenceFields } = + buildFreshInstallModelAndPromptDefaults(); + const defaultSettings: Settings = { defaultEditor: "default", sansFont: "Geist", monoFont: "Geist Mono", @@ -52,9 +66,10 @@ export class SettingsManager { apiKeys: {}, quickChat: { enabled: true, - modelConfigId: "anthropic::claude-sonnet-4-5-20250929", + modelConfigId: quickChatModelConfigId, shortcut: "Alt+Space", }, + ...modelPreferenceFields, }; // If no settings exist yet, save the defaults @@ -66,6 +81,8 @@ export class SettingsManager { return (settings as Settings) || defaultSettings; } catch (error) { console.error("Failed to get settings:", error); + const { quickChatModelConfigId: qcId, ...modelFields } = + buildFreshInstallModelAndPromptDefaults(); return { defaultEditor: "default", sansFont: "Geist", @@ -76,9 +93,10 @@ export class SettingsManager { apiKeys: {}, quickChat: { enabled: true, - modelConfigId: "anthropic::claude-3-5-sonnet-latest", + modelConfigId: qcId, shortcut: "Alt+Space", }, + ...modelFields, }; } } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 1a18315b..05848a62 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -762,10 +762,14 @@ function AppContent() { "open_settings", (event: { payload: { - tab: SettingsTabId; + tab: SettingsTabId | "quick-chat"; }; }) => { - setDefaultSettingsTab(event.payload.tab); + setDefaultSettingsTab( + event.payload.tab === "quick-chat" + ? "defaults" + : event.payload.tab, + ); dialogActions.openDialog(SETTINGS_DIALOG_ID); }, ); diff --git a/src/ui/components/AppSidebar.tsx b/src/ui/components/AppSidebar.tsx index e48bf694..f1a18687 100644 --- a/src/ui/components/AppSidebar.tsx +++ b/src/ui/components/AppSidebar.tsx @@ -9,6 +9,7 @@ import { SquarePlusIcon, ArrowBigUpIcon, EllipsisIcon, + CircleAlertIcon, } from "lucide-react"; import { Sidebar, @@ -77,6 +78,16 @@ import { dialogActions, useDialogStore } from "@core/infra/DialogStore"; import { projectQueries, useCreateProject } from "@core/chorus/api/ProjectAPI"; import { chatQueries } from "@core/chorus/api/ChatAPI"; import { useToggleProjectIsCollapsed } from "@core/chorus/api/ProjectAPI"; +import { + minimizedModelsActions, + useMinimizedModelsStore, +} from "@core/infra/MinimizedModelsStore"; +import * as ModelsAPI from "@core/chorus/api/ModelsAPI"; +import * as MessageAPI from "@core/chorus/api/MessageAPI"; +import { ProviderLogo } from "@ui/components/ui/provider-logo"; +import { Message } from "@core/chorus/ChatState"; +import * as Models from "@core/chorus/Models"; +import { useToolsDisabledStore } from "@core/infra/ToolsDisabledStore"; function isToday(date: Date) { const today = new Date(); @@ -431,15 +442,232 @@ function filterChatsForDisplay(chats: Chat[], currentChatId: string) { const NUM_DEFAULT_CHATS_TO_SHOW_BY_DEFAULT = 25; const NUM_PROJECT_CHATS_TO_SHOW_BY_DEFAULT = 10; +function MinimizedModelEntry({ + chatId, + modelId, + displayName, + modelConfig, + message, +}: { + chatId: string; + modelId: string; + displayName: string; + modelConfig: Models.ModelConfig | undefined; + message: Message | undefined; +}) { + const [retryRequested, setRetryRequested] = useState(false); + const dialogId = `minimized-sidebar-failure-${chatId}-${modelId}`; + const restartMessage = MessageAPI.useRestartMessage( + chatId, + message?.messageSetId ?? "", + message?.id ?? "", + ); + const toolsDisabledByChatId = useToolsDisabledStore( + (s) => s.toolsDisabledByChatId, + ); + + const hasEmptyIdleResponse = + !!message && + message.state === "idle" && + !message.errorMessage && + !message.text.trim() && + (message.parts.length === 0 || + message.parts.every((part) => part.content.trim().length === 0)); + const hasFailed = Boolean(message?.errorMessage) || hasEmptyIdleResponse; + const isRetrying = + retryRequested || + restartMessage.isPending || + message?.state === "streaming"; + const toolsDisabledForModel = + toolsDisabledByChatId.get(chatId)?.has(modelId) ?? false; + + useEffect(() => { + if ( + retryRequested && + message && + (message.state === "streaming" || message.text.trim().length > 0) + ) { + setRetryRequested(false); + minimizedModelsActions.expandModel(chatId, modelId); + } + }, [chatId, message, modelId, retryRequested]); + + useEffect(() => { + if (retryRequested && restartMessage.isError) { + setRetryRequested(false); + } + }, [restartMessage.isError, retryRequested]); + + return ( + <div className="px-1"> + <button + onClick={() => { + if (hasFailed && !isRetrying) { + dialogActions.openDialog(dialogId); + return; + } + minimizedModelsActions.expandModel(chatId, modelId); + }} + className="group/minimized flex items-center gap-2 w-full px-2 py-1.5 rounded-md hover:bg-sidebar-accent transition-colors cursor-pointer text-left" + > + {modelConfig && ( + <ProviderLogo size="sm" modelId={modelConfig.modelId} /> + )} + <span className="text-xs text-muted-foreground flex-1 truncate"> + {displayName} + {toolsDisabledForModel && ( + <span className="ml-1 text-[10px] uppercase tracking-wider text-amber-700"> + tools off + </span> + )} + </span> + {isRetrying && <RetroSpinner />} + {!isRetrying && hasFailed && ( + <CircleAlertIcon className="w-3 h-3 text-destructive" /> + )} + </button> + + <Dialog id={dialogId}> + <DialogContent className="max-w-md p-4"> + <DialogHeader> + <DialogTitle className="text-lg"> + Model failed + </DialogTitle> + <DialogDescription className="text-sm whitespace-pre-wrap"> + {message?.errorMessage ?? + "Model did not return a response."} + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button + variant="outline" + onClick={() => dialogActions.closeDialog(dialogId)} + > + Close + </Button> + <Button + disabled={ + !modelConfig || + !message || + restartMessage.isPending + } + onClick={() => { + if (!modelConfig || !message) return; + restartMessage.reset(); + setRetryRequested(true); + dialogActions.closeDialog(dialogId); + restartMessage.mutate( + { modelConfig }, + { + onSuccess: (streamingToken) => { + if (!streamingToken) { + setRetryRequested(false); + } + }, + onError: () => { + setRetryRequested(false); + }, + }, + ); + }} + > + {restartMessage.isPending + ? "Regenerating..." + : "Regenerate response"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </div> + ); +} + export function AppSidebarInner() { const projectsQuery = useQuery(ProjectAPI.projectQueries.list()); const chatsQuery = useQuery(ChatAPI.chatQueries.list()); const createProject = ProjectAPI.useCreateProject(); const location = useLocation(); - const currentChatId = location.pathname.split("/").pop()!; // well this is super hacky + const pathSegments = location.pathname.split("/").filter(Boolean); + const isChatRoute = pathSegments[0] === "chat" && Boolean(pathSegments[1]); + const currentChatId = isChatRoute ? pathSegments[1] : ""; const updateChatProject = ProjectAPI.useSetChatProject(); const getOrCreateNewChat = ChatAPI.useGetOrCreateNewChat(); + // Minimized models for the current chat + const minimizedModelsByChatId = useMinimizedModelsStore( + (s) => s.minimizedModelsByChatId, + ); + const minimizedModelIds = useMemo( + () => minimizedModelsByChatId.get(currentChatId) ?? new Set<string>(), + [currentChatId, minimizedModelsByChatId], + ); + const modelConfigsQuery = ModelsAPI.useModelConfigs(); + const messageSetsQuery = MessageAPI.useMessageSets( + currentChatId, + undefined, + { + enabled: isChatRoute, + }, + ); + + // Build list of minimized model entries for the sidebar panel + const latestMessageByModel = useMemo(() => { + const result = new Map<string, Message>(); + for (const messageSet of messageSetsQuery.data ?? []) { + if (messageSet.selectedBlockType === "tools") { + for (const message of messageSet.toolsBlock.chatMessages) { + result.set(message.model, message); + } + continue; + } + if (messageSet.selectedBlockType === "compare") { + for (const message of messageSet.compareBlock.messages) { + result.set(message.model, message); + } + continue; + } + if (messageSet.selectedBlockType === "chat") { + const message = messageSet.chatBlock.message; + if (message) result.set(message.model, message); + } + } + return result; + }, [messageSetsQuery.data]); + + const minimizedEntries = useMemo(() => { + return [...minimizedModelIds] + .map((modelId) => { + const config = modelConfigsQuery.data?.find( + (m) => m.id === modelId, + ); + const message = latestMessageByModel.get(modelId); + const hasEmptyIdleResponse = + !!message && + message.state === "idle" && + !message.errorMessage && + !message.text.trim() && + (message.parts.length === 0 || + message.parts.every( + (part) => part.content.trim().length === 0, + )); + const hasFailed = + Boolean(message?.errorMessage) || hasEmptyIdleResponse; + return { + modelId, + displayName: config?.displayName ?? modelId, + modelConfig: config, + message, + hasFailed, + }; + }) + .sort((a, b) => { + if (a.hasFailed !== b.hasFailed) { + return a.hasFailed ? 1 : -1; + } + return a.displayName.localeCompare(b.displayName); + }); + }, [minimizedModelIds, modelConfigsQuery.data, latestMessageByModel]); + const [showAllChats, setShowAllChats] = useState(false); const sensors = useSensors( @@ -568,6 +796,42 @@ export function AppSidebarInner() { </span> </button> + {/* Minimized models panel */} + {minimizedEntries.length > 0 && ( + <div className="mb-2"> + <div className="pt-2 px-3 mb-1 sidebar-label text-muted-foreground"> + Minimized + </div> + <div className="flex flex-col gap-0.5"> + {minimizedEntries.map( + ({ + modelId, + displayName, + modelConfig, + message, + }) => { + return ( + <MinimizedModelEntry + key={modelId} + chatId={ + currentChatId + } + modelId={modelId} + displayName={ + displayName + } + modelConfig={ + modelConfig + } + message={message} + /> + ); + }, + )} + </div> + </div> + )} + {/* add new project */} {hasNonQuickChats && ( <> diff --git a/src/ui/components/ChatInput.tsx b/src/ui/components/ChatInput.tsx index 1892f80c..e69b1bc7 100644 --- a/src/ui/components/ChatInput.tsx +++ b/src/ui/components/ChatInput.tsx @@ -3,7 +3,7 @@ import React from "react"; import { useAppContext } from "@ui/hooks/useAppContext"; import AutoExpandingTextarea from "./AutoExpandingTextarea"; import { AttachmentAddPill, AttachmentDropArea } from "./AttachmentsViews"; -import { AttachmentType } from "@core/chorus/Models"; +import { AttachmentType, ModelConfig } from "@core/chorus/Models"; import { MANAGE_MODELS_COMPARE_DIALOG_ID, ManageModelsBox, @@ -20,7 +20,7 @@ import { useWaitForAppMetadata } from "@ui/hooks/useWaitForAppMetadata"; import { ManageModelsButtonCompare } from "./ModelPills"; import { listen } from "@tauri-apps/api/event"; import { invoke } from "@tauri-apps/api/core"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import ToolsBox from "./ToolsBox"; import { useShortcut } from "@ui/hooks/useShortcut"; import { @@ -38,10 +38,15 @@ import { EmptyState } from "./EmptyState"; import { handleInputPasteWithAttachments } from "@ui/lib/utils"; import { inputActions, useInputStore } from "@core/infra/InputStore"; import { useSearchParams } from "react-router-dom"; -import * as ModelsAPI from "@core/chorus/api/ModelsAPI"; import * as DraftAPI from "@core/chorus/api/DraftAPI"; import * as ModelConfigChatAPI from "@core/chorus/api/ModelConfigChatAPI"; +import * as ModelsAPI from "@core/chorus/api/ModelsAPI"; import * as ProjectAPI from "@core/chorus/api/ProjectAPI"; +import { getFilteredModelConfigs } from "@core/utilities/ModelFiltering"; +import { useProviderVisibilityMap } from "@core/chorus/api/ProviderVisibilityAPI"; +import { PromptProfilePill } from "./PromptProfilePill"; +import { syncGlobalCompareMetadataToConfigIds } from "@core/chorus/ChatCompareSelection"; +import { modelConfigQueries } from "@core/chorus/api/ModelsAPI"; const DEFAULT_CHAT_INPUT_ID = "default-chat-input"; const REPLY_CHAT_INPUT_ID = "reply-chat-input"; @@ -75,6 +80,7 @@ export function ChatInput({ defaultReplyToModel, showScrollButton, handleScrollToBottom, + minimizedModels, }: { chatId: string; isNewChat: boolean | undefined; @@ -87,10 +93,23 @@ export function ChatInput({ defaultReplyToModel?: string; showScrollButton?: boolean; handleScrollToBottom?: () => void; + minimizedModels?: Set<string>; }) { - const selectedModelConfigsCompare = - ModelsAPI.useSelectedModelConfigsCompare(); + const queryClient = useQueryClient(); const modelConfigs = ModelsAPI.useModelConfigs(); + const providerVisibilityMap = useProviderVisibilityMap(); + const visibleModelConfigs = useMemo( + () => + getFilteredModelConfigs( + modelConfigs.data ?? [], + providerVisibilityMap, + null, + ), + [modelConfigs.data, providerVisibilityMap], + ); + + const chatCompareModelConfigs = + ModelConfigChatAPI.useChatCompareModelConfigs(chatId); const appMetadata = useWaitForAppMetadata(); const cautiousEnter = appMetadata["cautious_enter"] === "true"; @@ -139,9 +158,11 @@ export function ChatInput({ ModelConfigChatAPI.useUpdateReplyModelConfig(); const getReplyToModelConfig = useCallback( - (modelId: string | undefined) => { - return modelId - ? modelConfigs.data?.find((m) => m.modelId === modelId) + (raw: string | undefined) => { + return raw + ? modelConfigs.data?.find( + (m) => m.id === raw || m.modelId === raw, + ) : undefined; }, [modelConfigs.data], @@ -164,9 +185,8 @@ export function ChatInput({ const posthog = usePostHog(); - const addModelToCompareConfigs = MessageAPI.useAddModelToCompareConfigs(); - const updateSelectedModelConfigsCompare = - MessageAPI.useUpdateSelectedModelConfigsCompare(); + const updateSavedChatCompare = + ModelConfigChatAPI.useUpdateSavedModelConfigChat(); const createMessageSetPair = MessageAPI.useCreateMessageSetPair(); const createMessage = MessageAPI.useCreateMessage(); @@ -307,6 +327,9 @@ export function ChatInput({ setIsAnimatingToBottom(true); } + const applyChatCreationModelDefaults = + !isReply && isNewChat === true; + // Convert attachments await convertDraftAttachmentsToMessageAttachments.mutateAsync({ chatId, @@ -337,7 +360,9 @@ export function ChatInput({ void populateBlock.mutateAsync({ messageSetId: aiMessageSetId, blockType: BLOCK_TYPE, - replyToModelId: replyToModelConfig?.modelId, + replyToModelId: replyToModelConfig?.id, + excludedModelIds: minimizedModels, + applyChatCreationModelDefaults, }); }, }); @@ -364,48 +389,61 @@ export function ChatInput({ }; // -------------------------------------------------------------------------- - // Model management + // Model management (persisted per chat; never tied to draft/input state) // -------------------------------------------------------------------------- - /** - * Ensures a model config is selected - */ + const persistMainChatCompareIds = useCallback( + async (configIds: string[]): Promise<string[]> => { + const visibleIds = new Set(visibleModelConfigs.map((c) => c.id)); + const next = configIds.filter((id) => visibleIds.has(id)); + await updateSavedChatCompare.mutateAsync({ + chatId, + modelIds: next, + }); + await syncGlobalCompareMetadataToConfigIds( + next, + modelConfigs.data ?? [], + ); + void queryClient.invalidateQueries(modelConfigQueries.compare()); + return next; + }, + [ + chatId, + modelConfigs.data, + queryClient, + updateSavedChatCompare, + visibleModelConfigs, + ], + ); + const ensureCompareModelConfigSelected = useCallback( async (modelConfigId: string) => { - await addModelToCompareConfigs.mutateAsync({ - newSelectedModelConfigId: modelConfigId, - }); + const ids = chatCompareModelConfigs.map((m) => m.id); + if (ids.includes(modelConfigId)) return; + await persistMainChatCompareIds([...ids, modelConfigId]); }, - [addModelToCompareConfigs], + [chatCompareModelConfigs, persistMainChatCompareIds], ); const ensureCompareModelConfigDeselected = useCallback( async (modelConfigId: string) => { - const newModelConfigs = selectedModelConfigsCompare.data?.filter( - (m) => m.id !== modelConfigId, - ); - await updateSelectedModelConfigsCompare.mutateAsync({ - modelConfigs: newModelConfigs ?? [], - }); + const newIds = chatCompareModelConfigs + .filter((m) => m.id !== modelConfigId) + .map((m) => m.id); + const persistedIds = await persistMainChatCompareIds(newIds); - posthog.capture("selected_model_configs_updated", { - selectedModelConfigs: newModelConfigs?.map((m) => m.id) ?? [], + posthog?.capture("selected_model_configs_updated", { + selectedModelConfigs: persistedIds, modelConfigRemoved: modelConfigId, }); }, - [ - selectedModelConfigsCompare, - posthog, - updateSelectedModelConfigsCompare, - ], + [chatCompareModelConfigs, persistMainChatCompareIds, posthog], ); const toggleCompareModelConfig = useCallback( async (modelConfigId: string) => { - console.log("toggleCompareModelConfig", modelConfigId); try { - // Check if model is already selected - const isSelected = selectedModelConfigsCompare.data?.some( + const isSelected = chatCompareModelConfigs.some( (m) => m.id === modelConfigId, ); @@ -422,7 +460,7 @@ export function ChatInput({ } }, [ - selectedModelConfigsCompare, + chatCompareModelConfigs, ensureCompareModelConfigSelected, ensureCompareModelConfigDeselected, ], @@ -430,14 +468,65 @@ export function ChatInput({ const clearCompareModelConfigs = useCallback(() => { void (async () => { - await updateSelectedModelConfigsCompare.mutateAsync({ - modelConfigs: [], - }); - void posthog.capture("selected_model_configs_updated", { - selectedModelConfigs: [], + const persistedIds = await persistMainChatCompareIds([]); + void posthog?.capture("selected_model_configs_updated", { + selectedModelConfigs: persistedIds, }); })(); - }, [posthog, updateSelectedModelConfigsCompare]); + }, [persistMainChatCompareIds, posthog]); + + const selectAllCompareModelConfigs = useCallback( + (picked: ModelConfig[]) => { + void (async () => { + const visibleIds = new Set( + visibleModelConfigs.map((c) => c.id), + ); + const ids = picked + .filter((m) => visibleIds.has(m.id)) + .map((m) => m.id); + const persistedIds = await persistMainChatCompareIds(ids); + void posthog?.capture("selected_model_configs_updated", { + selectedModelConfigs: persistedIds, + }); + })(); + }, + [persistMainChatCompareIds, posthog, visibleModelConfigs], + ); + + const unionSelectAllCompareModelConfigs = useCallback( + (visibleSelectable: ModelConfig[]) => { + void (async () => { + const orderedIds: string[] = []; + const seen = new Set<string>(); + for (const m of chatCompareModelConfigs) { + if (!seen.has(m.id)) { + orderedIds.push(m.id); + seen.add(m.id); + } + } + for (const m of visibleSelectable) { + if (!seen.has(m.id)) { + orderedIds.push(m.id); + seen.add(m.id); + } + } + const persistedIds = + await persistMainChatCompareIds(orderedIds); + void posthog?.capture("selected_model_configs_updated", { + selectedModelConfigs: persistedIds, + viaUnionSelectAll: true, + }); + })(); + }, + [chatCompareModelConfigs, persistMainChatCompareIds, posthog], + ); + + const reorderMainChatCompare = useCallback( + (ordered: ModelConfig[]) => { + void persistMainChatCompareIds(ordered.map((m) => m.id)); + }, + [persistMainChatCompareIds], + ); // Update focus when dialog closes or chat id changes useEffect(() => { @@ -603,13 +692,11 @@ export function ChatInput({ </form> <div className="flex py-3 w-full"> <div className="flex justify-between w-full mx-auto"> - <div className="flex items-center gap-2 h-7 overflow-x-auto -mx-1 no-scrollbar overflow-y-hidden relative w-[30rem]"> + <div className="flex items-center gap-2 h-7 overflow-x-auto -mx-1 no-scrollbar overflow-y-hidden relative flex-1 min-w-0 pr-1"> <AttachmentAddPill onSelect={fileSelect.mutate} /> {!isReply && ( <ManageModelsButtonCompare - selectedModelConfigs={ - selectedModelConfigsCompare.data ?? [] - } + selectedModelConfigs={chatCompareModelConfigs} dialogId={MANAGE_MODELS_COMPARE_DIALOG_ID} /> )} @@ -625,6 +712,7 @@ export function ChatInput({ /> )} {!isReply && <ToolsBox />} + {!isReply && <PromptProfilePill chatId={chatId} />} </div> <div className="flex items-center gap-2 flex-shrink-0 h-7"> @@ -678,6 +766,14 @@ export function ChatInput({ onToggleModelConfig: (id) => void toggleCompareModelConfig(id), onClearModelConfigs: clearCompareModelConfigs, + onSelectAllModelConfigs: + selectAllCompareModelConfigs, + onUnionSelectAllVisibleModelConfigs: + unionSelectAllCompareModelConfigs, + selectedModelConfigsForChat: + chatCompareModelConfigs, + onReorderSelectedModelConfigs: + reorderMainChatCompare, }} /> )} @@ -693,10 +789,9 @@ export function ChatInput({ (m) => m.id === modelId, ); if (modelConfig) { - // Update the database with the selected model void updateReplyModelConfig.mutateAsync({ chatId, - modelId: modelConfig.modelId, + modelConfigId: modelConfig.id, }); } }, diff --git a/src/ui/components/DefaultsTab.tsx b/src/ui/components/DefaultsTab.tsx new file mode 100644 index 00000000..e952848e --- /dev/null +++ b/src/ui/components/DefaultsTab.tsx @@ -0,0 +1,652 @@ +/** + * Settings → Defaults consolidates default prompt profile, multi-model selection, fallback model, + * and ambient (quick) chat model configuration. Former "Ambient Chat" tab controls (shortcut, + * enable toggle, screen permissions) live here so a single "Ambient Chat" tab is not duplicated. + * + * Decision: Option A — one "Defaults" tab replaces the separate Ambient Chat tab (model selection + * was already app-wide via app_metadata); shortcut/accessibility moved under the same heading. + */ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "./ui/button"; +import { Separator } from "./ui/separator"; +import { Switch } from "./ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./ui/select"; +import { Checkbox } from "./ui/checkbox"; +import { + SettingsManager, + type Settings as CoreSettings, +} from "@core/utilities/Settings"; +import { usePromptProfiles } from "@core/chorus/api/PromptProfilesAPI"; +import { useModelProfiles } from "@core/chorus/api/ModelProfilesAPI"; +import { useModelConfigs } from "@core/chorus/api/ModelsAPI"; +import { useProviderVisibilityMap } from "@core/chorus/api/ProviderVisibilityAPI"; +import { getFilteredModelConfigs } from "@core/utilities/ModelFiltering"; +import type { ModelConfig } from "@core/chorus/Models"; +import { getProviderName } from "@core/chorus/Models"; +import { + modelConfigSupportsVision, + isModelConfigEffectivelyVisible, +} from "@core/chorus/chatCreationDefaults"; +import { toast } from "sonner"; +import { relaunch } from "@tauri-apps/plugin-process"; +import ShortcutRecorder from "./ShortcutRecorder"; +import { AccessibilitySettings } from "./AccessibilityCheck"; + +const NONE = "__none__"; + +function formatCostSuffix(config: ModelConfig): string { + if ( + config.promptPricePerToken === undefined && + config.completionPricePerToken === undefined + ) { + return "cost unknown"; + } + const inM = + config.promptPricePerToken !== undefined + ? (config.promptPricePerToken * 1_000_000).toFixed(2) + : "?"; + const outM = + config.completionPricePerToken !== undefined + ? (config.completionPricePerToken * 1_000_000).toFixed(2) + : "?"; + return `$${inM} / 1M input · $${outM} / 1M output`; +} + +const PROVIDER_LABELS: Record<string, string> = { + anthropic: "Anthropic", + openai: "OpenAI", + google: "Google AI (Gemini)", + openrouter: "OpenRouter", + grok: "Grok", + perplexity: "Perplexity", + ollama: "Ollama", + lmstudio: "LM Studio", +}; + +const PROVIDER_ORDER = [ + "anthropic", + "openai", + "google", + "openrouter", + "grok", + "perplexity", + "ollama", + "lmstudio", +]; + +function groupByProvider(models: ModelConfig[]): [string, ModelConfig[]][] { + const groups = new Map<string, ModelConfig[]>(); + for (const m of models) { + const provider = getProviderName(m.modelId); + const existing = groups.get(provider) ?? []; + existing.push(m); + groups.set(provider, existing); + } + return Array.from(groups.entries()).sort(([a], [b]) => { + const ai = PROVIDER_ORDER.indexOf(a); + const bi = PROVIDER_ORDER.indexOf(b); + if (ai === -1 && bi === -1) return a.localeCompare(b); + if (ai === -1) return 1; + if (bi === -1) return -1; + return ai - bi; + }); +} + +export function DefaultsTab({ + onOpenVisibleModels, +}: { + onOpenVisibleModels: () => void; +}) { + const settingsManager = SettingsManager.getInstance(); + const { data: profiles } = usePromptProfiles(); + const { data: modelProfiles } = useModelProfiles(); + const { data: allConfigs = [] } = useModelConfigs(); + const providerVisibilityMap = useProviderVisibilityMap(); + + // Defaults are global (not profile-scoped), so we intentionally skip active + // model profile filtering here. Selected defaults may be filtered out at + // chat creation time if they fall outside the active profile. + const visibleModels = useMemo( + () => + getFilteredModelConfigs( + allConfigs, + providerVisibilityMap, + null, + ).filter((c) => c.isEnabled && !c.isInternal && !c.isDeprecated), + [allConfigs, providerVisibilityMap], + ); + + const visibilityMap = useMemo(() => { + const m = new Map<string, boolean>(); + if (providerVisibilityMap) { + for (const [k, v] of providerVisibilityMap) { + m.set(k, v); + } + } + return m; + }, [providerVisibilityMap]); + + const [defaultPromptProfileId, setDefaultPromptProfileId] = useState< + string | null + >(null); + const [defaultFallbackModel, setDefaultFallbackModel] = useState< + string | null + >(null); + const [defaultFallbackModelProfileId, setDefaultFallbackModelProfileId] = + useState<string | null>(null); + const [defaultAmbientChatModel, setDefaultAmbientChatModel] = useState< + string | null + >(null); + const [defaultChatModels, setDefaultChatModels] = useState<string[] | null>( + null, + ); + + const [quickChatEnabled, setQuickChatEnabled] = useState(true); + const [quickChatShortcut, setQuickChatShortcut] = useState("Alt+Space"); + + const persist = useCallback( + async (partial: Partial<CoreSettings>) => { + const current = await settingsManager.get(); + await settingsManager.set({ ...current, ...partial }); + }, + [settingsManager], + ); + + useEffect(() => { + const load = async () => { + const s = await settingsManager.get(); + setDefaultPromptProfileId(s.defaultPromptProfileId ?? null); + setDefaultFallbackModel(s.defaultFallbackModel ?? null); + setDefaultFallbackModelProfileId( + s.defaultFallbackModelProfileId ?? null, + ); + setDefaultAmbientChatModel(s.defaultAmbientChatModel ?? null); + setDefaultChatModels(s.defaultChatModels ?? null); + setQuickChatEnabled(s.quickChat?.enabled ?? true); + setQuickChatShortcut(s.quickChat?.shortcut ?? "Alt+Space"); + }; + void load(); + }, [settingsManager]); + + const visionVisibleModels = useMemo( + () => visibleModels.filter((m) => modelConfigSupportsVision(m)), + [visibleModels], + ); + + const fallbackSelectValue = useMemo(() => { + if (!defaultFallbackModel) return NONE; + const c = visibleModels.find((m) => m.id === defaultFallbackModel); + if (c && isModelConfigEffectivelyVisible(c, visibilityMap)) { + return defaultFallbackModel; + } + return NONE; + }, [defaultFallbackModel, visibleModels, visibilityMap]); + + const ambientSelectValue = useMemo(() => { + if (!defaultAmbientChatModel) return NONE; + const c = visibleModels.find((m) => m.id === defaultAmbientChatModel); + if ( + c && + isModelConfigEffectivelyVisible(c, visibilityMap) && + modelConfigSupportsVision(c) + ) { + return defaultAmbientChatModel; + } + return NONE; + }, [defaultAmbientChatModel, visibleModels, visibilityMap]); + + const staleFallback = + !!defaultFallbackModel && fallbackSelectValue === NONE; + const staleAmbient = + !!defaultAmbientChatModel && ambientSelectValue === NONE; + + const compatibleFallbackProfiles = useMemo( + () => + (modelProfiles ?? []).filter( + (p) => + !!defaultFallbackModel && + p.modelConfigIds.includes(defaultFallbackModel), + ), + [modelProfiles, defaultFallbackModel], + ); + + const fallbackProfileIncompatible = + !!defaultFallbackModelProfileId && + !compatibleFallbackProfiles.some( + (p) => p.id === defaultFallbackModelProfileId, + ); + + const onDefaultQcShortcutClick = async () => { + setQuickChatShortcut("Alt+Space"); + setQuickChatEnabled(true); + const currentSettings = await settingsManager.get(); + await settingsManager.set({ + ...currentSettings, + quickChat: { + ...currentSettings.quickChat, + shortcut: "Alt+Space", + enabled: true, + }, + }); + }; + + const staleChatModelIds = useMemo( + () => + (defaultChatModels ?? []).filter( + (id) => !visibleModels.some((m) => m.id === id), + ), + [defaultChatModels, visibleModels], + ); + + const toggleDefaultChatModel = (id: string, checked: boolean) => { + void (async () => { + const visibleIds = new Set(visibleModels.map((c) => c.id)); + if (!visibleIds.has(id)) return; + + let next: string[] | null; + if (!defaultChatModels || defaultChatModels.length === 0) { + next = checked ? [id] : null; + } else if (defaultChatModels.includes(id)) { + const filtered = defaultChatModels.filter((x) => x !== id); + next = filtered.length === 0 ? null : filtered; + } else { + next = checked ? [...defaultChatModels, id] : defaultChatModels; + } + + setDefaultChatModels(next); + await persist({ defaultChatModels: next }); + })(); + }; + + const providerGroups = useMemo( + () => groupByProvider(visibleModels), + [visibleModels], + ); + + return ( + <div className="space-y-6 max-w-2xl"> + <div> + <h2 className="text-2xl font-semibold mb-2">Defaults</h2> + <p className="text-muted-foreground text-sm"> + Configure defaults applied when you create a new chat. These + are read once at creation time and do not change existing + chats. + </p> + </div> + + <div className="space-y-2"> + <label className="font-semibold">Default Prompt Profile</label> + <p className="text-sm text-muted-foreground"> + Automatically injected into new regular chats. + </p> + <Select + value={defaultPromptProfileId ?? NONE} + onValueChange={(v) => { + const next = v === NONE ? null : v; + setDefaultPromptProfileId(next); + void persist({ defaultPromptProfileId: next }); + }} + > + <SelectTrigger className="w-full"> + <SelectValue placeholder="None" /> + </SelectTrigger> + <SelectContent> + <SelectItem value={NONE}>None</SelectItem> + {(profiles ?? []).map((p) => ( + <SelectItem key={p.id} value={p.id}> + {p.name} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + <Separator /> + + <div className="space-y-2"> + <label className="font-semibold">Default Fallback Model</label> + <p className="text-sm text-muted-foreground"> + Single model for new chats when{" "} + <span className="font-medium text-foreground/90"> + Default Chat Models + </span>{" "} + is cleared. Takes priority over your current multi-model + (⌘J) list. For recovery when a chat's models are no + longer visible, the same order applies after filtering. + </p> + <Select + value={fallbackSelectValue} + onValueChange={(v) => { + const next = v === NONE ? null : v; + setDefaultFallbackModel(next); + void persist({ defaultFallbackModel: next }); + }} + > + <SelectTrigger className="w-full"> + <SelectValue placeholder="Select a model…" /> + </SelectTrigger> + <SelectContent> + <SelectItem value={NONE}>None</SelectItem> + {visibleModels.map((c) => ( + <SelectItem key={c.id} value={c.id}> + <span className="flex flex-col gap-0.5 text-left"> + <span>{c.displayName}</span> + <span className="text-xs text-muted-foreground font-normal"> + {formatCostSuffix(c)} + </span> + </span> + </SelectItem> + ))} + </SelectContent> + </Select> + {staleFallback && ( + <p className="text-xs text-muted-foreground"> + Previously selected model is no longer in your visible + models. + </p> + )} + </div> + + {defaultFallbackModel && + fallbackSelectValue !== NONE && + (modelProfiles?.length ?? 0) > 0 && ( + <div className="space-y-2"> + <label className="font-semibold"> + Model Profile for Fallback + </label> + <p className="text-sm text-muted-foreground"> + When set, the fallback model must belong to this + profile. Only profiles that include the selected + fallback model are shown. + </p> + <Select + value={defaultFallbackModelProfileId ?? NONE} + onValueChange={(v) => { + const next = v === NONE ? null : v; + setDefaultFallbackModelProfileId(next); + void persist({ + defaultFallbackModelProfileId: next, + }); + }} + > + <SelectTrigger className="w-full"> + <SelectValue placeholder="None" /> + </SelectTrigger> + <SelectContent> + <SelectItem value={NONE}>None</SelectItem> + {compatibleFallbackProfiles.map((p) => ( + <SelectItem key={p.id} value={p.id}> + {p.name} + </SelectItem> + ))} + </SelectContent> + </Select> + {fallbackProfileIncompatible && ( + <p className="text-xs text-destructive"> + The saved profile no longer includes the + selected fallback model. Clear it to avoid + conflicts. + </p> + )} + </div> + )} + + <Separator /> + + <div className="space-y-2"> + <label className="font-semibold"> + Default Ambient Chat Model{" "} + <span className="text-muted-foreground font-normal"> + (vision-capable models only) + </span> + </label> + <p className="text-sm text-muted-foreground"> + Model used for ambient chat. Only visible models that accept + image input are listed. + </p> + <Select + value={ambientSelectValue} + disabled={visionVisibleModels.length === 0} + onValueChange={(v) => { + const next = v === NONE ? null : v; + setDefaultAmbientChatModel(next); + void persist({ defaultAmbientChatModel: next }); + }} + > + <SelectTrigger className="w-full"> + <SelectValue + placeholder={ + visionVisibleModels.length === 0 + ? "No vision models" + : "Select a model…" + } + /> + </SelectTrigger> + <SelectContent> + <SelectItem value={NONE}>None</SelectItem> + {visionVisibleModels.map((c) => ( + <SelectItem key={c.id} value={c.id}> + <span className="flex flex-col gap-0.5 text-left"> + <span>{c.displayName}</span> + <span className="text-xs text-muted-foreground font-normal"> + {formatCostSuffix(c)} + </span> + </span> + </SelectItem> + ))} + </SelectContent> + </Select> + {visionVisibleModels.length === 0 && ( + <p className="text-xs text-muted-foreground"> + No vision-capable models available. Enable a vision + model in{" "} + <button + type="button" + className="underline hover:no-underline" + onClick={onOpenVisibleModels} + > + Visible Models + </button> + . + </p> + )} + {staleAmbient && ( + <p className="text-xs text-muted-foreground"> + Previously selected model is no longer available as a + visible vision model. + </p> + )} + </div> + + <Separator /> + + <div className="space-y-2"> + <div className="flex items-center justify-between gap-2"> + <label className="font-semibold">Default Chat Models</label> + <Button + variant="outline" + size="sm" + onClick={() => { + setDefaultChatModels(null); + void persist({ defaultChatModels: null }); + }} + > + Clear + </Button> + </div> + <p className="text-sm text-muted-foreground"> + Optional explicit list: every new regular chat starts with + exactly these models (in order). When cleared, new chats use{" "} + <span className="font-medium text-foreground/90"> + Default Fallback Model + </span>{" "} + if set, otherwise your ⌘J multi-model list, then the first + visible model. + </p> + {staleChatModelIds.length > 0 && ( + <p className="text-xs text-muted-foreground"> + Some saved defaults are no longer visible and will be + skipped. + </p> + )} + <div className="max-h-72 overflow-y-auto border rounded-md p-3 space-y-4"> + {visibleModels.length === 0 ? ( + <p className="text-sm text-muted-foreground"> + No visible models. Configure them in{" "} + <button + type="button" + className="underline hover:no-underline" + onClick={onOpenVisibleModels} + > + Visible Models + </button> + . + </p> + ) : ( + providerGroups.map(([provider, models]) => { + const label = PROVIDER_LABELS[provider] ?? provider; + return ( + <div key={provider}> + <div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1.5"> + {label} + </div> + <div className="pl-1 space-y-2"> + {models.map((m) => ( + <label + key={m.id} + className="flex items-start gap-2 text-sm cursor-pointer" + > + <Checkbox + className="mt-0.5" + checked={ + defaultChatModels?.includes( + m.id, + ) ?? false + } + onCheckedChange={( + checked, + ) => + toggleDefaultChatModel( + m.id, + !!checked, + ) + } + /> + <span> + <span className="font-medium"> + {m.displayName} + </span> + <span className="text-muted-foreground text-xs block"> + {formatCostSuffix(m)} + </span> + </span> + </label> + ))} + </div> + </div> + ); + }) + )} + </div> + </div> + + <Separator /> + + <div> + <h3 className="text-lg font-semibold mb-3">Ambient Chat</h3> + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <div className="space-y-0.5"> + <label className="font-semibold"> + Ambient Chat + </label> + <p className="text-sm text-muted-foreground"> + Start an ambient chat with{" "} + <span className="font-mono"> + {quickChatShortcut} + </span> + </p> + </div> + <Switch + checked={quickChatEnabled} + onCheckedChange={(enabled) => { + setQuickChatEnabled(enabled); + void (async () => { + const current = await settingsManager.get(); + await settingsManager.set({ + ...current, + quickChat: { + ...current.quickChat, + enabled, + }, + }); + })(); + }} + /> + </div> + + <div className="space-y-2"> + <label className="font-semibold"> + Keyboard Shortcut + </label> + <p className="text-sm text-muted-foreground"> + Enter the shortcut you want to use to start an + ambient chat. + </p> + <ShortcutRecorder + value={quickChatShortcut} + onChange={(shortcut) => { + setQuickChatShortcut(shortcut); + void (async () => { + const current = await settingsManager.get(); + await settingsManager.set({ + ...current, + quickChat: { + ...current.quickChat, + shortcut, + }, + }); + })(); + }} + /> + <div className="flex justify-end gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => void onDefaultQcShortcutClick()} + > + Set to default + </Button> + <Button + variant="default" + size="sm" + onClick={() => { + if (!quickChatShortcut.trim()) { + toast.error("Invalid shortcut", { + description: + "Shortcut cannot be empty", + }); + return; + } + void relaunch().catch(console.error); + }} + > + Save and restart + </Button> + </div> + </div> + + <Separator /> + + <AccessibilitySettings /> + </div> + </div> + </div> + ); +} diff --git a/src/ui/components/ManageModelsBox.tsx b/src/ui/components/ManageModelsBox.tsx index cbf1dc27..f1aa2a31 100644 --- a/src/ui/components/ManageModelsBox.tsx +++ b/src/ui/components/ManageModelsBox.tsx @@ -47,6 +47,21 @@ import { hasApiKey } from "@core/utilities/ProxyUtils"; import * as ModelsAPI from "@core/chorus/api/ModelsAPI"; import * as MessageAPI from "@core/chorus/api/MessageAPI"; import { useSettings } from "./hooks/useSettings"; +import { useShortcut } from "@ui/hooks/useShortcut"; +import { useProviderVisibilityMap } from "@core/chorus/api/ProviderVisibilityAPI"; +import { + useActiveModelProfile, + useModelProfiles, + useSetActiveModelProfile, +} from "@core/chorus/api/ModelProfilesAPI"; +import { getFilteredModelConfigs } from "@core/utilities/ModelFiltering"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./ui/select"; // Helper function to filter models by search terms const filterBySearch = (models: ModelConfig[], searchTerms: string[]) => { @@ -97,9 +112,17 @@ const isNewModel = (newUntil: string | undefined): boolean => { type ModelPickerMode = | { - type: "default"; // multiselect for updating selectedModelConfigs (deprecated) + type: "default"; onToggleModelConfig: (id: string) => void; onClearModelConfigs: () => void; + onSelectAllModelConfigs: (modelConfigs: ModelConfig[]) => void; + /** ⌘⇧A: add all visible models without removing current selection */ + onUnionSelectAllVisibleModelConfigs?: ( + modelConfigs: ModelConfig[], + ) => void; + /** When set, UI reflects this list instead of global compare metadata */ + selectedModelConfigsForChat?: ModelConfig[]; + onReorderSelectedModelConfigs?: (modelConfigs: ModelConfig[]) => void; } | { type: "add"; // used for adding to an existing set @@ -295,6 +318,35 @@ export const MANAGE_MODELS_COMPARE_DIALOG_ID = "manage-models-compare"; export const MANAGE_MODELS_COMPARE_INLINE_DIALOG_ID = "manage-models-compare-inline"; // dialog for the inline add model button +function ProfileSelector() { + const { data: profiles } = useModelProfiles(); + const activeProfile = useActiveModelProfile(); + const setActiveProfile = useSetActiveModelProfile(); + + if (!profiles || profiles.length === 0) return null; + + return ( + <Select + value={activeProfile?.id ?? "none"} + onValueChange={(value) => + setActiveProfile.mutate(value === "none" ? null : value) + } + > + <SelectTrigger className="h-7 text-xs px-2.5 py-1"> + <SelectValue placeholder="No Profile" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="none">No Profile</SelectItem> + {profiles.map((p) => ( + <SelectItem key={p.id} value={p.id}> + {p.name} + </SelectItem> + ))} + </SelectContent> + </Select> + ); +} + /** Main component that handles all model grouping and UI. */ export function ManageModelsBox({ mode, @@ -330,11 +382,16 @@ export function ManageModelsBox({ const selectedModelConfigsCompareResult = ModelsAPI.useSelectedModelConfigsCompare(); - const selectedModelConfigsCompare = useMemo( + const selectedModelConfigsCompareGlobal = useMemo( () => selectedModelConfigsCompareResult.data ?? [], [selectedModelConfigsCompareResult.data], ); + const selectedModelConfigsCompare = + mode.type === "default" && mode.selectedModelConfigsForChat + ? mode.selectedModelConfigsForChat + : selectedModelConfigsCompareGlobal; + const updateSelectedModelConfigsCompare = MessageAPI.useUpdateSelectedModelConfigsCompare(); const modelConfigs = ModelsAPI.useModelConfigs(); @@ -386,9 +443,13 @@ export function ManageModelsBox({ const items = [...selectedModelConfigsCompare]; const [moved] = items.splice(result.source.index, 1); items.splice(result.destination.index, 0, moved); - await updateSelectedModelConfigsCompare.mutateAsync({ - modelConfigs: items, - }); + if (mode.type === "default" && mode.onReorderSelectedModelConfigs) { + mode.onReorderSelectedModelConfigs(items); + } else { + await updateSelectedModelConfigsCompare.mutateAsync({ + modelConfigs: items, + }); + } } // Helper function to render model pills for dragging @@ -474,21 +535,23 @@ export function ManageModelsBox({ }, [navigate]); // Compute filtered model groups based on search + const providerVisibilityMap = useProviderVisibilityMap(); + const activeProfile = useActiveModelProfile(); const modelGroups = useMemo(() => { const searchTerms = searchQuery .toLowerCase() .split(" ") .filter(Boolean); - const nonInternalModelConfigs = - modelConfigs.data?.filter((m) => !m.isInternal) ?? []; - const systemModels = nonInternalModelConfigs.filter( - (m) => m.author === "system", - ); - const userModels = nonInternalModelConfigs.filter( - (m) => m.author === "user", + const filtered = getFilteredModelConfigs( + modelConfigs.data ?? [], + providerVisibilityMap, + activeProfile, ); + const systemModels = filtered.filter((m) => m.author === "system"); + const userModels = filtered.filter((m) => m.author === "user"); + const localModels = systemModels.filter((m) => { const provider = getProviderName(m.modelId); return provider === "ollama" || provider === "lmstudio"; @@ -525,7 +588,7 @@ export function ManageModelsBox({ openrouter: filterBySearch(openrouterModels, searchTerms), directByProvider, }; - }, [modelConfigs.data, searchQuery]); + }, [modelConfigs.data, searchQuery, providerVisibilityMap, activeProfile]); useLayoutEffect(() => { if (!listRef.current) return; @@ -540,6 +603,60 @@ export function ManageModelsBox({ }); }, [searchQuery]); + // All visible, selectable models — used by "Select All" button and shortcut. + // Excludes models hidden behind an API key wall, and OpenRouter models when + // that section is collapsed. + const selectableVisibleModels = useMemo(() => { + const all = [ + ...Object.values(modelGroups.directByProvider).flat(), + ...modelGroups.custom, + ...modelGroups.local, + ...(showOpenRouter ? modelGroups.openrouter : []), + ]; + return all.filter((m) => { + const provider = getProviderName(m.modelId); + if (provider === "ollama" || provider === "lmstudio") return true; + if (!apiKeys || !provider) return false; + return hasApiKey( + provider.toLowerCase() as keyof typeof apiKeys, + apiKeys, + ); + }); + }, [modelGroups, showOpenRouter, apiKeys]); + + /** Profile models the user can actually select (API keys, list visibility). */ + const profileSelectableConfigs = useMemo(() => { + if (!activeProfile) return []; + const selectableIds = new Set(selectableVisibleModels.map((m) => m.id)); + const byId = new Map((modelConfigs.data ?? []).map((m) => [m.id, m])); + const ordered: ModelConfig[] = []; + for (const configId of activeProfile.modelConfigIds) { + if (!selectableIds.has(configId)) continue; + const c = byId.get(configId); + if (c) ordered.push(c); + } + return ordered; + }, [activeProfile, selectableVisibleModels, modelConfigs.data]); + + useShortcut( + ["meta", "shift", "a"], + () => { + if (mode.type === "default") { + if (mode.onUnionSelectAllVisibleModelConfigs) { + mode.onUnionSelectAllVisibleModelConfigs( + selectableVisibleModels, + ); + } else { + mode.onSelectAllModelConfigs(selectableVisibleModels); + } + } + }, + { + enableOnDialogIds: [id], + enabled: mode.type === "default", + }, + ); + return ( <> <CommandDialog @@ -630,7 +747,51 @@ export function ManageModelsBox({ setSearchQuery(value); }} autoFocus + trailing={ + mode.type === "default" ? ( + <> + <span className="select-none"> + Select All + </span> + <span className="text-[10px] inline-flex items-center gap-0.5 bg-muted-foreground/10 rounded px-1 py-0.5 font-sans"> + <span>⌘</span> + <span>⇧</span> + <span>A</span> + </span> + </> + ) : undefined + } /> + <div className="px-3 py-2 border-b border-border flex items-center justify-between gap-2"> + <ProfileSelector /> + {mode.type === "default" && ( + <Button + type="button" + variant="outline" + size="sm" + className="h-7 flex-shrink-0 px-3 text-xs font-medium" + onClick={(e) => { + e.preventDefault(); + mode.onSelectAllModelConfigs( + profileSelectableConfigs, + ); + }} + disabled={ + !activeProfile || + profileSelectableConfigs.length === 0 + } + title={ + !activeProfile + ? "Choose a profile to replace the selection with its models" + : profileSelectableConfigs.length === 0 + ? "No models from this profile are available with your current keys and filters" + : "Replace selection with this profile's models (deselects others)" + } + > + Apply + </Button> + )} + </div> </div> <CommandList ref={listRef}> <CommandEmpty>No models found</CommandEmpty> diff --git a/src/ui/components/ModelProfilesTab.tsx b/src/ui/components/ModelProfilesTab.tsx new file mode 100644 index 00000000..57ee541f --- /dev/null +++ b/src/ui/components/ModelProfilesTab.tsx @@ -0,0 +1,352 @@ +import { useState } from "react"; +import { Button } from "./ui/button"; +import { + useModelProfiles, + useCreateModelProfile, + useUpdateModelProfile, + useDeleteModelProfile, +} from "@core/chorus/api/ModelProfilesAPI"; +import { useModelConfigs } from "@core/chorus/api/ModelsAPI"; +import { useProviderVisibilityMap } from "@core/chorus/api/ProviderVisibilityAPI"; +import { getFilteredModelConfigs } from "@core/utilities/ModelFiltering"; +import { + ModelConfig, + getProviderName, + ModelProfile, +} from "@core/chorus/Models"; +import { Loader2, Plus, Trash2, Pencil, Check, X } from "lucide-react"; +import { v4 as uuidv4 } from "uuid"; +import { Input } from "./ui/input"; +import { Checkbox } from "@ui/components/ui/checkbox"; + +const PROVIDER_LABELS: Record<string, string> = { + anthropic: "Anthropic", + openai: "OpenAI", + google: "Google AI (Gemini)", + openrouter: "OpenRouter", + grok: "Grok", + perplexity: "Perplexity", + ollama: "Ollama", + lmstudio: "LM Studio", +}; + +const PROVIDER_ORDER = [ + "anthropic", + "openai", + "google", + "openrouter", + "grok", + "perplexity", + "ollama", + "lmstudio", +]; + +function groupByProvider(models: ModelConfig[]): [string, ModelConfig[]][] { + const groups = new Map<string, ModelConfig[]>(); + for (const m of models) { + const provider = getProviderName(m.modelId); + const existing = groups.get(provider) ?? []; + existing.push(m); + groups.set(provider, existing); + } + return Array.from(groups.entries()).sort(([a], [b]) => { + const ai = PROVIDER_ORDER.indexOf(a); + const bi = PROVIDER_ORDER.indexOf(b); + if (ai === -1 && bi === -1) return a.localeCompare(b); + if (ai === -1) return 1; + if (bi === -1) return -1; + return ai - bi; + }); +} + +function ModelChecklist({ + visibleModels, + selectedIds, + onChange, +}: { + visibleModels: ModelConfig[]; + selectedIds: string[]; + onChange: (ids: string[]) => void; +}) { + const providerGroups = groupByProvider(visibleModels); + + const toggleOne = (id: string, checked: boolean) => { + onChange( + checked + ? [...selectedIds, id] + : selectedIds.filter((x) => x !== id), + ); + }; + + const toggleAll = (models: ModelConfig[], checked: boolean) => { + const ids = models.map((m) => m.id); + if (checked) { + onChange(Array.from(new Set([...selectedIds, ...ids]))); + } else { + const idSet = new Set(ids); + onChange(selectedIds.filter((id) => !idSet.has(id))); + } + }; + + if (visibleModels.length === 0) { + return ( + <p className="text-sm text-muted-foreground"> + No visible models available. Go to "Visible Models" to enable + models first. + </p> + ); + } + + return ( + <div className="max-h-72 overflow-y-auto space-y-4"> + {providerGroups.map(([provider, models]) => { + const allSelected = models.every((m) => + selectedIds.includes(m.id), + ); + const someSelected = models.some((m) => + selectedIds.includes(m.id), + ); + const label = PROVIDER_LABELS[provider] ?? provider; + + return ( + <div key={provider}> + <div className="flex items-center gap-2 mb-1.5"> + <Checkbox + checked={allSelected} + data-state={ + someSelected && !allSelected + ? "indeterminate" + : undefined + } + onCheckedChange={(checked) => + toggleAll(models, !!checked) + } + /> + <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> + {label} + </span> + </div> + <div className="pl-6 space-y-1"> + {models.map((m) => ( + <div + key={m.id} + className="flex items-center gap-2 text-sm" + > + <Checkbox + checked={selectedIds.includes(m.id)} + onCheckedChange={(checked) => + toggleOne(m.id, !!checked) + } + /> + {m.displayName} + </div> + ))} + </div> + </div> + ); + })} + </div> + ); +} + +function EditProfileForm({ + profile, + visibleModels, + onSave, + onCancel, +}: { + profile: ModelProfile; + visibleModels: ModelConfig[]; + onSave: (name: string, modelConfigIds: string[]) => void; + onCancel: () => void; +}) { + const [name, setName] = useState(profile.name); + const [selectedIds, setSelectedIds] = useState<string[]>( + profile.modelConfigIds, + ); + + return ( + <div className="border rounded-lg p-4 space-y-4 bg-muted/30"> + <Input + placeholder="Profile Name" + value={name} + onChange={(e) => setName(e.target.value)} + /> + <ModelChecklist + visibleModels={visibleModels} + selectedIds={selectedIds} + onChange={setSelectedIds} + /> + <div className="flex gap-2"> + <Button + size="sm" + onClick={() => onSave(name, selectedIds)} + disabled={!name || selectedIds.length === 0} + > + <Check className="w-3.5 h-3.5 mr-1" /> + Save + </Button> + <Button size="sm" variant="ghost" onClick={onCancel}> + <X className="w-3.5 h-3.5 mr-1" /> + Cancel + </Button> + </div> + </div> + ); +} + +export function ModelProfilesTab() { + const { data: profiles, isLoading } = useModelProfiles(); + const { data: allModels } = useModelConfigs(); + const providerVisibilityMap = useProviderVisibilityMap(); + const createProfile = useCreateModelProfile(); + const updateProfile = useUpdateModelProfile(); + const deleteProfile = useDeleteModelProfile(); + + const [isCreating, setIsCreating] = useState(false); + const [newName, setNewName] = useState(""); + const [newSelectedModels, setNewSelectedModels] = useState<string[]>([]); + const [editingId, setEditingId] = useState<string | null>(null); + + if (isLoading || !allModels) { + return ( + <div className="flex items-center justify-center h-full"> + <Loader2 className="w-8 h-8 animate-spin text-muted-foreground" /> + </div> + ); + } + + const visibleModels = getFilteredModelConfigs( + allModels, + providerVisibilityMap, + null, + ); + + const handleCreate = () => { + createProfile.mutate({ + id: uuidv4(), + name: newName, + modelConfigIds: newSelectedModels, + }); + setIsCreating(false); + setNewName(""); + setNewSelectedModels([]); + }; + + const handleUpdate = ( + id: string, + name: string, + modelConfigIds: string[], + ) => { + updateProfile.mutate({ id, name, modelConfigIds }); + setEditingId(null); + }; + + return ( + <div className="space-y-8 max-w-2xl"> + <div> + <h2 className="text-2xl font-semibold mb-2">Model Profiles</h2> + <p className="text-sm text-muted-foreground"> + Create named sets of models to quickly switch between them + in chat. Profiles draw from your visible models — configure + which models are visible in the "Visible Models" tab. + </p> + </div> + + <Button + onClick={() => { + setIsCreating(true); + setEditingId(null); + }} + disabled={isCreating} + > + <Plus className="w-4 h-4 mr-2" /> + Create New Profile + </Button> + + {isCreating && ( + <div className="border rounded-lg p-4 space-y-4"> + <Input + placeholder="Profile Name" + value={newName} + onChange={(e) => setNewName(e.target.value)} + /> + <ModelChecklist + visibleModels={visibleModels} + selectedIds={newSelectedModels} + onChange={setNewSelectedModels} + /> + <div className="flex gap-2"> + <Button + onClick={handleCreate} + disabled={ + !newName || newSelectedModels.length === 0 + } + > + Save + </Button> + <Button + variant="ghost" + onClick={() => { + setIsCreating(false); + setNewName(""); + setNewSelectedModels([]); + }} + > + Cancel + </Button> + </div> + </div> + )} + + <div className="space-y-4"> + {profiles?.map((p) => + editingId === p.id ? ( + <EditProfileForm + key={p.id} + profile={p} + visibleModels={visibleModels} + onSave={(name, ids) => + handleUpdate(p.id, name, ids) + } + onCancel={() => setEditingId(null)} + /> + ) : ( + <div + key={p.id} + className="border rounded-lg p-4 flex items-center justify-between" + > + <div> + <h3 className="font-semibold">{p.name}</h3> + <p className="text-xs text-muted-foreground"> + {p.modelConfigIds.length} models + </p> + </div> + <div className="flex items-center gap-1"> + <Button + variant="ghost" + size="sm" + onClick={() => { + setEditingId(p.id); + setIsCreating(false); + }} + > + <Pencil className="w-4 h-4" /> + </Button> + <Button + variant="ghost" + size="sm" + onClick={() => + deleteProfile.mutate({ id: p.id }) + } + > + <Trash2 className="w-4 h-4 text-destructive" /> + </Button> + </div> + </div> + ), + )} + </div> + </div> + ); +} diff --git a/src/ui/components/MultiChat.tsx b/src/ui/components/MultiChat.tsx index 85200238..46ba00ae 100644 --- a/src/ui/components/MultiChat.tsx +++ b/src/ui/components/MultiChat.tsx @@ -1,4 +1,12 @@ -import { useEffect, useRef, useState, useCallback, memo, useMemo } from "react"; +import { + useEffect, + useRef, + useState, + useCallback, + memo, + useMemo, + useLayoutEffect, +} from "react"; import React from "react"; import { useParams, @@ -8,6 +16,7 @@ import { } from "react-router-dom"; import { toast } from "sonner"; import { Button } from "./ui/button"; +import { LayoutGroup, motion } from "framer-motion"; import { FileTextIcon, ExternalLinkIcon, @@ -20,6 +29,7 @@ import { Loader2, SearchIcon, Maximize2Icon, + Minimize2Icon, RemoveFormattingIcon, RefreshCcwIcon, StopCircleIcon, @@ -82,6 +92,31 @@ import { Toggle } from "./ui/toggle"; import { CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; import { Collapsible } from "./ui/collapsible"; import * as _ from "lodash"; +import { + minimizedModelsActions, + useMinimizedModelsStore, +} from "@core/infra/MinimizedModelsStore"; +import { + modelOrderActions, + useModelOrderStore, +} from "@core/infra/ModelOrderStore"; +import { + DndContext, + DragOverlay, + PointerSensor, + useSensor, + useSensors, + useDraggable, + closestCenter, +} from "@dnd-kit/core"; +import type { + DragStartEvent, + DragEndEvent, + DragOverEvent, +} from "@dnd-kit/core"; +import { restrictToHorizontalAxis } from "@dnd-kit/modifiers"; +import { SortableColumnItem } from "./SortableColumnItem"; +import { useToolsDisabledStore } from "@core/infra/ToolsDisabledStore"; import { getToolsetIcon, UserToolCall, @@ -93,7 +128,7 @@ import { SidebarTrigger } from "@ui/components/ui/sidebar"; import { useSidebar } from "@ui/hooks/useSidebar"; import { useShortcut } from "@ui/hooks/useShortcut"; import { projectDisplayName, sendTauriNotification } from "@ui/lib/utils"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ManageModelsBox } from "./ManageModelsBox"; import RepliesDrawer from "./RepliesDrawer"; import useElementScrollDetection from "@ui/hooks/useScrollDetection"; @@ -113,17 +148,26 @@ import { filterReplyMessageSets } from "@ui/lib/replyUtils"; import * as MessageAPI from "@core/chorus/api/MessageAPI"; import * as ChatAPI from "@core/chorus/api/ChatAPI"; import * as ProjectAPI from "@core/chorus/api/ProjectAPI"; +import { fetchSavedModelConfigChat } from "@core/chorus/api/ModelConfigChatAPI"; +import * as ModelConfigChatAPI from "@core/chorus/api/ModelConfigChatAPI"; import * as ModelsAPI from "@core/chorus/api/ModelsAPI"; import * as AttachmentsAPI from "@core/chorus/api/AttachmentsAPI"; import * as DraftAPI from "@core/chorus/api/DraftAPI"; import SimpleCopyButton from "./unused/CopyButton"; import { MessageCostDisplay } from "./MessageCostDisplay"; +import { + computeInitialChatCompareModelConfigIds, + syncGlobalCompareMetadataToConfigIds, +} from "@core/chorus/ChatCompareSelection"; import * as AppMetadataAPI from "@core/chorus/api/AppMetadataAPI"; +import { applyDefaultPromptProfileForChat } from "@core/chorus/chatCreationDefaults"; import { isPermissionGranted, requestPermission, } from "@tauri-apps/plugin-notification"; +type DragListeners = ReturnType<typeof useDraggable>["listeners"]; + // ---------------------------------- // Sub-components // ---------------------------------- @@ -994,9 +1038,15 @@ function DeepResearchNotificationButton({ message }: { message: Message }) { function ToolsAIMessageViewInner({ message, isQuickChatWindow, + selected, + showReorderOverlay, + isReply = false, }: { message: Message; isQuickChatWindow: boolean; + selected: boolean; + showReorderOverlay: boolean; + isReply?: boolean; }) { // combine tool calls with tool results const messagePartsSandwiched: MessagePartWithResults[] = message.parts @@ -1034,56 +1084,65 @@ function ToolsAIMessageViewInner({ }) .filter((p) => p !== undefined); return ( - <div - className={`relative overflow-y-auto select-text ${ - isQuickChatWindow - ? "py-2.5 border !border-special max-w-full inline-block break-words px-3.5 rounded-xl" - : "p-4 pb-6" - }`} - > - {(message.parts.length === 0 || - _.every(message.parts.map((p) => !p.content))) && - message.state === "idle" ? ( - <div className="text-sm text-muted-foreground/50 uppercase font-[350] font-geist-mono tracking-wider"> - <ErrorView message={message} /> - </div> - ) : ( - <> - {messagePartsSandwiched.map((part) => ( - <MessagePartView - key={part.level} - part={part} - messageState={message.state} - /> - ))} - {message.state === "streaming" && ( - <RetroSpinner className="mt-2" /> - )} - <DeepResearchNotificationHandler message={message} /> - <DeepResearchNotificationButton message={message} /> - {message.errorMessage && ( - <div className="text-md rounded-md my-1 items-center justify-between font-[350]"> - <div className="flex items-center text-destructive font-medium"> - {message.errorMessage} + <div className="relative"> + <div + className={`relative overflow-y-auto select-text transition-[filter] duration-200 ${ + isQuickChatWindow + ? "py-2.5 border !border-special max-w-full inline-block break-words px-3.5 rounded-xl" + : "p-4 pb-6" + } ${selected && !isQuickChatWindow && !isReply ? "blur-[1.5px]" : ""}`} + > + {(message.parts.length === 0 || + _.every(message.parts.map((p) => !p.content))) && + message.state === "idle" ? ( + <div className="text-sm text-muted-foreground/50 uppercase font-[350] font-geist-mono tracking-wider"> + <ErrorView message={message} /> + </div> + ) : ( + <> + {messagePartsSandwiched.map((part) => ( + <MessagePartView + key={part.level} + part={part} + messageState={message.state} + /> + ))} + {message.state === "streaming" && ( + <RetroSpinner className="mt-2" /> + )} + <DeepResearchNotificationHandler message={message} /> + <DeepResearchNotificationButton message={message} /> + {message.errorMessage && ( + <div className="text-md rounded-md my-1 items-center justify-between font-[350]"> + <div className="flex items-center text-destructive font-medium"> + {message.errorMessage} + </div> </div> - </div> - )} - </> + )} + </> + )} + {/* // {streamStartTime && !isQuickChatWindow && ( + // <Metrics + // text={message.text} + // startTime={streamStartTime} + // isStreaming={message.state === "streaming"} + // /> + // )} */} + <MessageCostDisplay + costUsd={message.costUsd} + promptTokens={message.promptTokens} + completionTokens={message.completionTokens} + isStreaming={message.state === "streaming"} + isQuickChatWindow={isQuickChatWindow} + /> + </div> + {showReorderOverlay && ( + <div className="absolute inset-0 z-[4] flex items-center justify-center pointer-events-none"> + <div className="select-none rounded-md border border-border-accent/60 bg-background/85 px-3 py-1 text-[11px] font-geist-mono uppercase tracking-[0.16em] text-accent-700 shadow-sm backdrop-blur-sm"> + Drag to reorder + </div> + </div> )} - {/* // {streamStartTime && !isQuickChatWindow && ( - // <Metrics - // text={message.text} - // startTime={streamStartTime} - // isStreaming={message.state === "streaming"} - // /> - // )} */} - <MessageCostDisplay - costUsd={message.costUsd} - promptTokens={message.promptTokens} - completionTokens={message.completionTokens} - isStreaming={message.state === "streaming"} - isQuickChatWindow={isQuickChatWindow} - /> </div> ); } @@ -1134,12 +1193,20 @@ export function ToolsMessageView({ isLastRow, isOnlyMessage, isReply = false, + onMinimize, + onStop, + onDeselect, + dragHandleProps, }: { message: Message; isQuickChatWindow: boolean; isLastRow: boolean; isOnlyMessage: boolean; isReply?: boolean; + onMinimize?: () => void; + onStop?: () => void; + onDeselect?: () => void; + dragHandleProps?: DragListeners; }) { const navigate = useNavigate(); // const [raw, setRaw] = useState(false); @@ -1166,6 +1233,9 @@ export function ToolsMessageView({ replyToId: message.id, }); const modelConfigsQuery = ModelsAPI.useModelConfigs(); + const toolsDisabledByChatId = useToolsDisabledStore( + (s) => s.toolsDisabledByChatId, + ); // // Set stream start time when streaming begins // useEffect(() => { // if (message.state === "streaming" && !streamStartTime) { @@ -1181,6 +1251,8 @@ export function ToolsMessageView({ const modelConfig = modelConfigsQuery.data?.find( (m) => m.id === message.model, ); + const toolsDisabledForModel = + toolsDisabledByChatId.get(message.chatId)?.has(message.model) ?? false; const messageClasses = [ "relative", @@ -1189,13 +1261,15 @@ export function ToolsMessageView({ !isQuickChatWindow && (message.selected || isReply) ? "!border-special" : "", - isLastRow && !isQuickChatWindow && !message.selected - ? "cursor-pointer" - : "", + isLastRow && !isQuickChatWindow ? "cursor-pointer" : "", !message.selected ? "opacity-70 hover:opacity-100" : "", ] .filter(Boolean) .join(" "); + const showReorderOverlay = + message.selected && !isQuickChatWindow && !isOnlyMessage; + const dragAnywhereProps = + showReorderOverlay && dragHandleProps ? dragHandleProps : {}; function onReplyClick() { if (message.replyChatId) { @@ -1214,13 +1288,18 @@ export function ToolsMessageView({ style={{ overflowWrap: "anywhere", // tailwind doesn't support this yet }} + {...dragAnywhereProps} onClick={(e) => { - if (message.selected) return; - // Don't trigger selection if user is selecting text + // Don't trigger selection changes if user is selecting text if (window.getSelection()?.toString()) { e.stopPropagation(); return; } + if (message.selected && isLastRow) { + onDeselect?.(); + return; + } + if (message.selected) return; if (isLastRow) { selectMessage.mutate({ chatId: message.chatId, @@ -1258,20 +1337,18 @@ export function ToolsMessageView({ className="-mt-[1px]" /> <div className="text-sm"> - {modelConfig?.displayName} + <span> + {modelConfig?.displayName} + </span> + {toolsDisabledForModel && ( + <span className="ml-1 text-[10px] uppercase tracking-wider text-amber-700"> + tools off + </span> + )} </div> </div> )} </div> - {!isOnlyMessage && ( - <div - className={`text-accent-600 px-2 flex text-sm tracking-wider font-[350] - ${isQuickChatWindow ? "bg-gray-200" : "bg-background"} animate-brief-flash font-geist-mono uppercase - ${message.selected ? "opacity-100" : "opacity-0"}`} - > - In Chat - </div> - )} </div> <div className={`no-print mr-3 flex items-center h-6 gap-2 @@ -1294,6 +1371,7 @@ export function ToolsMessageView({ messageId: message.id, }); + onStop?.(); }} > <StopCircleIcon className="w-3.5 h-3.5" /> @@ -1410,6 +1488,28 @@ export function ToolsMessageView({ </TooltipContent> </Tooltip> + {onMinimize && ( + <Tooltip> + <TooltipTrigger asChild> + <button + className="hover:text-foreground" + onClick={(e) => { + e.stopPropagation(); + onMinimize(); + }} + > + <Minimize2Icon + strokeWidth={1.5} + className="w-3.5 h-3.5" + /> + </button> + </TooltipTrigger> + <TooltipContent> + Minimize + </TooltipContent> + </Tooltip> + )} + {!isQuickChatWindow && !isReply && ( <Tooltip> <TooltipTrigger asChild> @@ -1435,6 +1535,9 @@ export function ToolsMessageView({ <ToolsAIMessageViewInner message={message} isQuickChatWindow={isQuickChatWindow} + selected={message.selected} + showReorderOverlay={showReorderOverlay} + isReply={isReply} /> {/* Reply button at bottom overlapping border (only show if there are no replies) */} @@ -1473,96 +1576,449 @@ export const MANAGE_MODELS_TOOLS_DIALOG_ID = "manage-models-compare"; export const MANAGE_MODELS_TOOLS_INLINE_DIALOG_ID = "manage-models-compare-inline"; // dialog for the inline add model button +export function MinimizedToolsColumnView({ + message, + onExpand, +}: { + message: Message; + onExpand: () => void; +}) { + const modelConfigsQuery = ModelsAPI.useModelConfigs(); + const modelConfig = modelConfigsQuery.data?.find( + (m) => m.id === message.model, + ); + const hasEmptyIdleResponse = + message.state === "idle" && + !message.errorMessage && + !message.text.trim() && + (message.parts.length === 0 || + message.parts.every((part) => part.content.trim().length === 0)); + + return ( + <button + onClick={onExpand} + className="group/minimized flex items-center gap-2 w-full px-2 py-1.5 rounded-md border-[0.090rem] hover:bg-accent/50 transition-colors cursor-pointer text-left" + > + {modelConfig && ( + <ProviderLogo size="sm" modelId={modelConfig.modelId} /> + )} + <span className="text-xs text-muted-foreground flex-1 truncate"> + {modelConfig?.displayName ?? message.model} + </span> + {message.state === "streaming" && <RetroSpinner />} + {(message.errorMessage || hasEmptyIdleResponse) && ( + <CircleAlertIcon className="w-3 h-3 text-destructive" /> + )} + <Maximize2Icon className="w-3 h-3 text-muted-foreground opacity-0 group-hover/minimized:opacity-100 transition-opacity flex-none" /> + </button> + ); +} + function ToolsBlockView({ messageSetId, toolsBlock, isLastRow = false, isQuickChatWindow, + minimizedModels, + onMinimize, }: { messageSetId: string; toolsBlock: ToolsBlock; isLastRow: boolean; isQuickChatWindow: boolean; + minimizedModels: Set<string>; + onMinimize: (modelId: string) => void; }) { const { chatId } = useParams(); + const queryClient = useQueryClient(); const { elementRef, shouldShowScrollbar } = useElementScrollDetection(); - - const addModelToCompareConfigs = MessageAPI.useAddModelToCompareConfigs(); + const modelConfigsQuery = ModelsAPI.useModelConfigs(); + const chatCompareModelConfigs = + ModelConfigChatAPI.useChatCompareModelConfigs(chatId!); + const appendModelToChatCompare = + ModelConfigChatAPI.useAppendModelConfigToChatCompare(chatId!); const addMessageToToolsBlock = MessageAPI.useAddMessageToToolsBlock( chatId!, ); + const deselectToolsMessages = MessageAPI.useDeselectToolsMessages(); + const containerRef = useRef<HTMLDivElement>(null); + + const customOrder = useModelOrderStore((state) => + chatId ? state.modelOrderByChatId.get(chatId) : undefined, + ); + const setModelOrder = useModelOrderStore((state) => state.setModelOrder); + const setCurrentVisualOrder = useModelOrderStore( + (state) => state.setCurrentVisualOrder, + ); + + // Track which models finished streaming and in what order, so we can sort + // finished models to the left (first finished = leftmost slot) when no + // custom drag-and-drop order is set. + const [finishedModelsOrder, setFinishedModelsOrder] = useState<string[]>( + [], + ); + const prevMessageStatesRef = useRef<Map<string, string>>(new Map()); + const trackedMessageSetIdRef = useRef<string | undefined>(undefined); + + const getDisplayName = useCallback( + (modelId: string) => + modelConfigsQuery.data?.find((m) => m.id === modelId) + ?.displayName ?? modelId, + [modelConfigsQuery.data], + ); + const selectedModelConfigs = chatCompareModelConfigs; + const currentModelIds = useMemo( + () => new Set(toolsBlock.chatMessages.map((m) => m.model)), + [toolsBlock.chatMessages], + ); + const pendingModelConfigs = useMemo(() => { + if (selectedModelConfigs.length === 0) return []; + return selectedModelConfigs.filter( + (modelConfig) => + !currentModelIds.has(modelConfig.id) && + !minimizedModels.has(modelConfig.id), + ); + }, [selectedModelConfigs, currentModelIds, minimizedModels]); + const hasNormalizedInitialSelectionRef = useRef(false); + + useLayoutEffect(() => { + if (trackedMessageSetIdRef.current !== messageSetId) { + trackedMessageSetIdRef.current = messageSetId; + prevMessageStatesRef.current = new Map(); + setFinishedModelsOrder([]); + } + + const nextFinishedModelsOrder = [...finishedModelsOrder]; + let didChange = false; + for (const message of toolsBlock.chatMessages) { + const prev = prevMessageStatesRef.current.get(message.model); + if ( + prev === "streaming" && + message.state !== "streaming" && + !nextFinishedModelsOrder.includes(message.model) + ) { + nextFinishedModelsOrder.push(message.model); + didChange = true; + } + prevMessageStatesRef.current.set(message.model, message.state); + } + + if (didChange) { + setFinishedModelsOrder(nextFinishedModelsOrder); + } + }, [toolsBlock.chatMessages, messageSetId, finishedModelsOrder]); + + // New behavior: tools chats should start with no selected message. + // For existing chats that still have legacy selection state, clear it once. + useEffect(() => { + if (!chatId) return; + if (hasNormalizedInitialSelectionRef.current) return; + if (toolsBlock.chatMessages.length === 0) return; + hasNormalizedInitialSelectionRef.current = true; + + if (toolsBlock.chatMessages.some((m) => m.selected)) { + deselectToolsMessages.mutate({ + chatId, + messageSetId, + }); + } + }, [chatId, messageSetId, toolsBlock.chatMessages, deselectToolsMessages]); + + // Deselect when the user clicks outside the tools block while a model is selected + const anyMessageSelected = useMemo( + () => toolsBlock.chatMessages.some((m) => m.selected), + [toolsBlock.chatMessages], + ); + useEffect(() => { + if (!isLastRow || !anyMessageSelected || !chatId) return; + + function handleClick(e: MouseEvent) { + if (!(e.target instanceof Node)) return; + if (deselectToolsMessages.isPending) return; + if ( + containerRef.current && + !containerRef.current.contains(e.target) + ) { + deselectToolsMessages.mutate({ chatId: chatId!, messageSetId }); + } + } + + document.addEventListener("click", handleClick); + return () => document.removeEventListener("click", handleClick); + }, [ + isLastRow, + anyMessageSelected, + chatId, + messageSetId, + deselectToolsMessages, + ]); + + // Auto-minimize models that returned no response or errored + useEffect(() => { + for (const message of toolsBlock.chatMessages) { + const hasEmptyIdleResponse = + message.state === "idle" && + !message.errorMessage && + !message.text.trim() && + (message.parts.length === 0 || + message.parts.every( + (part) => part.content.trim().length === 0, + )); + if ( + !minimizedModels.has(message.model) && + (message.errorMessage || hasEmptyIdleResponse) + ) { + onMinimize(message.model); + } + } + }, [toolsBlock.chatMessages, minimizedModels, onMinimize]); + + const activeMessages = useMemo( + () => + [...toolsBlock.chatMessages] + .filter((m) => !minimizedModels.has(m.model)) + .sort((a, b) => { + // Respect explicit drag-and-drop ordering if the user has set one. + if (customOrder) { + const aIdx = customOrder.indexOf(a.model); + const bIdx = customOrder.indexOf(b.model); + if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx; + if (aIdx !== -1) return -1; + if (bIdx !== -1) return 1; + } + // Default: finished (idle) models go left of still-streaming/loading + // models, sorted by completion order (first finished = leftmost slot). + const aIdle = a.state === "idle"; + const bIdle = b.state === "idle"; + if (aIdle !== bIdle) return aIdle ? -1 : 1; + if (aIdle && bIdle) { + const aFinishIdx = finishedModelsOrder.indexOf(a.model); + const bFinishIdx = finishedModelsOrder.indexOf(b.model); + if (aFinishIdx !== -1 && bFinishIdx !== -1) + return aFinishIdx - bFinishIdx; + } + return getDisplayName(a.model).localeCompare( + getDisplayName(b.model), + ); + }), + [ + toolsBlock.chatMessages, + minimizedModels, + customOrder, + finishedModelsOrder, + getDisplayName, + ], + ); + const toolsItemOrder = useMemo( + () => activeMessages.map((m) => m.model), + [activeMessages], + ); + + // Keep the store in sync with the current visual order so cmd+number + // keybindings can index into the same order the user sees on screen. + useEffect(() => { + if (chatId) { + setCurrentVisualOrder(chatId, toolsItemOrder); + } + }, [chatId, toolsItemOrder, setCurrentVisualOrder]); + const handleAddModel = (modelId: string) => { - // First add the model to the selected models list - addModelToCompareConfigs.mutate({ - newSelectedModelConfigId: modelId, - }); - // Then add it to the current message set - addMessageToToolsBlock.mutate({ - messageSetId, - modelId, - }); + void (async () => { + try { + await appendModelToChatCompare.mutateAsync(modelId); + const ids = (await fetchSavedModelConfigChat(chatId!)) ?? []; + await syncGlobalCompareMetadataToConfigIds( + ids, + modelConfigsQuery.data ?? [], + ); + void queryClient.invalidateQueries( + ModelsAPI.modelConfigQueries.compare(), + ); + addMessageToToolsBlock.mutate({ + messageSetId, + modelId, + }); + if (chatId) { + const current = + customOrder ?? activeMessages.map((m) => m.model); + setModelOrder(chatId, [...current, modelId]); + } + } catch (error) { + console.error("Failed to add model to chat compare", error); + } + })(); }; + const [activeDragId, setActiveDragId] = useState<string | null>(null); + const [overId, setOverId] = useState<string | null>(null); + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }), + ); + + function onDragStart({ active }: DragStartEvent) { + setActiveDragId(active.id as string); + } + + function onDragOver({ over }: DragOverEvent) { + setOverId(over ? (over.id as string) : null); + } + + function onDragEnd({ active, over }: DragEndEvent) { + setActiveDragId(null); + setOverId(null); + if (!over || active.id === over.id) return; + const oldIndex = activeMessages.findIndex((m) => m.model === active.id); + const newIndex = activeMessages.findIndex((m) => m.model === over.id); + if (oldIndex === -1 || newIndex === -1) return; + const newOrder = activeMessages.map((m) => m.model); + newOrder.splice(oldIndex, 1); + newOrder.splice(newIndex, 0, active.id as string); + if (chatId) setModelOrder(chatId, newOrder); + } + + const handleDeselect = useCallback(() => { + if (chatId) deselectToolsMessages.mutate({ chatId, messageSetId }); + }, [chatId, messageSetId, deselectToolsMessages]); + return ( - <div - ref={elementRef} - className={`flex w-full h-fit pb-2 pr-5 gap-2 ${ - // get horizontal scroll bars, plus hackily disable y scrolling - // because we're seeing scroll bars when we shouldn't - "overflow-x-auto scrollbar-on-scroll overflow-y-hidden" - } - ${shouldShowScrollbar ? "is-scrolling" : ""} - ${!isQuickChatWindow ? "px-10" : ""}`} - > - {toolsBlock.chatMessages.map((message, _index) => ( + <LayoutGroup id={`tools-${messageSetId}`}> + <div ref={containerRef} className="flex w-full h-fit"> + {/* Main scrollable area: active (non-minimized) models only */} <div - key={message.id} - className={ - isQuickChatWindow - ? "w-full max-w-prose" - : `w-full flex-1 min-w-[450px] max-w-[550px]` + ref={elementRef} + className={`flex flex-1 h-fit pb-2 pr-5 gap-2 ${ + // get horizontal scroll bars, plus hackily disable y scrolling + // because we're seeing scroll bars when we shouldn't + "overflow-x-auto scrollbar-on-scroll overflow-y-hidden" } + ${shouldShowScrollbar ? "is-scrolling" : ""} + ${!isQuickChatWindow ? "px-10" : ""}`} > - <ToolsMessageView - message={message} - // shortcutNumber={isLastRow ? index + 1 : undefined} - isLastRow={isLastRow} - isQuickChatWindow={isQuickChatWindow} - isOnlyMessage={toolsBlock.chatMessages.length === 1} - /> - </div> - ))} - {isLastRow && !isQuickChatWindow && ( - <div> - <button - // brighten border in dark mode bc it's hard to see - className="w-14 flex-none text-sm text-muted-foreground hover:text-foreground rounded-md border-[0.090rem] py-[0.6rem] px-2 mt-2 h-fit border-dashed" - onClick={() => { - dialogActions.openDialog( - MANAGE_MODELS_TOOLS_INLINE_DIALOG_ID, - ); - }} + <DndContext + sensors={sensors} + collisionDetection={closestCenter} + modifiers={[restrictToHorizontalAxis]} + onDragStart={onDragStart} + onDragOver={onDragOver} + onDragEnd={onDragEnd} > - <div className="flex flex-col items-center gap-1 py-1"> - <PlusIcon className="font-medium w-3 h-3" /> - Add + <div className="flex flex-1 gap-2"> + {activeMessages.map((message) => ( + <motion.div + key={message.model} + layout + layoutId={`tools-col-${message.model}-${messageSetId}`} + data-tools-message-id={message.id} + > + <SortableColumnItem + id={message.model} + disabled={!message.selected} + activeDragId={activeDragId} + overId={overId} + itemOrder={toolsItemOrder} + className={ + isQuickChatWindow + ? "w-full max-w-prose" + : "w-full flex-1 min-w-[450px] max-w-[550px]" + } + > + {(listeners) => ( + <ToolsMessageView + message={message} + isLastRow={isLastRow} + isQuickChatWindow={ + isQuickChatWindow + } + isOnlyMessage={ + toolsBlock.chatMessages + .length === 1 + } + onMinimize={ + toolsBlock.chatMessages + .length > 1 + ? () => + onMinimize( + message.model, + ) + : undefined + } + onStop={() => + onMinimize(message.model) + } + onDeselect={handleDeselect} + dragHandleProps={listeners} + /> + )} + </SortableColumnItem> + </motion.div> + ))} </div> - </button> - - {/* Add Model dialog (can go basically anywhere, but shouldn't be inside the button) */} - <ManageModelsBox - id={MANAGE_MODELS_TOOLS_INLINE_DIALOG_ID} - mode={{ - type: "add", - checkedModelConfigIds: toolsBlock.chatMessages.map( - (m) => m.model, - ), - onAddModel: handleAddModel, - }} - /> + <DragOverlay> + {activeDragId && ( + <div className="bg-background border rounded-md shadow-lg px-4 py-2 cursor-grabbing opacity-90"> + <span className="text-sm"> + {getDisplayName(activeDragId)} + </span> + </div> + )} + </DragOverlay> + </DndContext> + {isLastRow && !isQuickChatWindow && ( + <div className="flex items-end gap-2 self-end"> + {pendingModelConfigs.length > 0 && ( + <div className="flex flex-col gap-1"> + <div className="text-[10px] uppercase tracking-wider text-muted-foreground"> + Included in next response + </div> + {pendingModelConfigs.map((modelConfig) => ( + <button + key={modelConfig.id} + className="px-2 py-0.5 text-[10px] uppercase tracking-wider text-muted-foreground border rounded-md bg-muted/30 max-w-[140px] truncate text-left hover:bg-muted/50" + onClick={() => + onMinimize(modelConfig.id) + } + > + {modelConfig.displayName} + </button> + ))} + </div> + )} + <div className="flex flex-col items-center"> + <button + // brighten border in dark mode bc it's hard to see + className="w-14 flex-none text-sm text-muted-foreground hover:text-foreground rounded-md border-[0.090rem] py-[0.6rem] px-2 h-fit border-dashed" + onClick={() => { + dialogActions.openDialog( + MANAGE_MODELS_TOOLS_INLINE_DIALOG_ID, + ); + }} + > + <div className="flex flex-col items-center gap-1 py-1"> + <PlusIcon className="font-medium w-3 h-3" /> + Add + </div> + </button> + + {/* Add Model dialog (can go basically anywhere, but shouldn't be inside the button) */} + <ManageModelsBox + id={MANAGE_MODELS_TOOLS_INLINE_DIALOG_ID} + mode={{ + type: "add", + checkedModelConfigIds: + toolsBlock.chatMessages.map( + (m) => m.model, + ), + onAddModel: handleAddModel, + }} + /> + </div> + </div> + )} </div> - )} - </div> + </div> + </LayoutGroup> ); } @@ -1596,6 +2052,11 @@ type MessageSetViewProps = { isQuickChatWindow: boolean; userMessageRef: React.RefObject<HTMLDivElement> | undefined; messageSetRef: React.RefObject<HTMLDivElement> | undefined; + minimizedModels: Set<string>; + onToggleMinimize: (modelId: string) => void; // for CompareBlockView (deprecated path) + movedRightModels: Set<string>; // for CompareBlockView (deprecated path) + onModelStopped: (modelId: string) => void; // for CompareBlockView (deprecated path) + onMinimize: (modelId: string) => void; // for ToolsBlockView }; const MessageSetView = memo( @@ -1605,6 +2066,11 @@ const MessageSetView = memo( isQuickChatWindow, userMessageRef, // a ref that will be applied to user message container, if there is one messageSetRef, // a ref that will be applied to the message set container + minimizedModels, + onToggleMinimize, + movedRightModels, + onModelStopped, + onMinimize, }: MessageSetViewProps) => { const { chatId } = useParams(); @@ -1658,6 +2124,10 @@ const MessageSetView = memo( compareBlock={messageSet.compareBlock} isLastRow={isLastRow} isQuickChatWindow={isQuickChatWindow} + minimizedModels={minimizedModels} + onToggleMinimize={onToggleMinimize} + movedRightModels={movedRightModels} + onModelStopped={onModelStopped} /> ) : messageSet.selectedBlockType === "chat" ? ( <ChatBlockView @@ -1672,6 +2142,8 @@ const MessageSetView = memo( toolsBlock={messageSet.toolsBlock} isLastRow={isLastRow} isQuickChatWindow={isQuickChatWindow} + minimizedModels={minimizedModels} + onMinimize={onMinimize} /> ) : messageSet.selectedBlockType === "brainstorm" ? ( <BrainstormBlockView @@ -1722,8 +2194,42 @@ export default function MultiChat() { const location = useLocation(); const appMetadata = useWaitForAppMetadata(); const messageSetsQuery = MessageAPI.useMessageSets(chatId!); + const modelConfigsQuery = ModelsAPI.useModelConfigs(); + const savedCompareModelsQuery = ModelConfigChatAPI.useSavedModelConfigChat( + chatId!, + ); + const updateSavedCompareModels = + ModelConfigChatAPI.useUpdateSavedModelConfigChat(); + const savedCompareLegacyInitRef = useRef<string | null>(null); const [searchParams] = useSearchParams(); + // One-time backfill: older main chats have no saved_model_configs_chats row yet + useEffect(() => { + if (!chatId || !chatQuery.data) return; + if (chatQuery.data.quickChat || chatQuery.data.replyToId) return; + if ( + !savedCompareModelsQuery.isSuccess || + savedCompareModelsQuery.data !== null + ) { + return; + } + if (savedCompareLegacyInitRef.current === chatId) return; + savedCompareLegacyInitRef.current = chatId; + void (async () => { + const ids = await computeInitialChatCompareModelConfigIds(); + await updateSavedCompareModels.mutateAsync({ + chatId, + modelIds: ids, + }); + })(); + }, [ + chatId, + chatQuery.data, + savedCompareModelsQuery.isSuccess, + savedCompareModelsQuery.data, + updateSavedCompareModels, + ]); + // Extract replyId from query parameters const replyChatId = searchParams.get("replyId"); @@ -1757,6 +2263,108 @@ export default function MultiChat() { const inputRef = useRef<HTMLTextAreaElement>(null); const chatContainerRef = useRef<HTMLDivElement>(null); + // Minimized model state — lives in a shared store so AppSidebar can read it + const minimizedModelsByChatId = useMinimizedModelsStore( + (s) => s.minimizedModelsByChatId, + ); + const minimizedModels = useMemo( + () => minimizedModelsByChatId.get(chatId ?? "") ?? new Set<string>(), + [chatId, minimizedModelsByChatId], + ); + + const [movedRightModels, setMovedRightModels] = useState<Set<string>>( + new Set(), + ); + const previousLayoutStateChatIdRef = useRef<string | undefined>(undefined); + + // Reset per-chat layout state when navigating between chats + useEffect(() => { + const previousChatId = previousLayoutStateChatIdRef.current; + if (previousChatId && previousChatId !== chatId) { + minimizedModelsActions.clearChat(previousChatId); + modelOrderActions.clearChat(previousChatId); + } + previousLayoutStateChatIdRef.current = chatId; + setMovedRightModels(new Set()); + + return () => { + if (chatId) { + minimizedModelsActions.clearChat(chatId); + modelOrderActions.clearChat(chatId); + } + }; + }, [chatId]); + + const queryClient = useQueryClient(); + const newChatDefaultsSyncRef = useRef<string | null>(null); + + useEffect(() => { + newChatDefaultsSyncRef.current = null; + }, [chatId]); + + useEffect(() => { + if (!chatId || isQuickChatWindow || !chatQuery.data?.isNewChat) { + return; + } + if (!messageSetsQuery.data || messageSetsQuery.data.length > 0) { + return; + } + if (newChatDefaultsSyncRef.current === chatId) { + return; + } + newChatDefaultsSyncRef.current = chatId; + + void (async () => { + await applyDefaultPromptProfileForChat(chatId); + await queryClient.invalidateQueries({ + queryKey: ["promptProfiles"], + }); + })(); + }, [ + chatId, + isQuickChatWindow, + chatQuery.data?.isNewChat, + messageSetsQuery.data, + queryClient, + ]); + + const handleMinimize = useCallback( + (modelId: string) => { + if (!chatId) return; + if (chatContainerRef.current) { + pendingScrollTopRef.current = + chatContainerRef.current.scrollTop; + } + minimizedModelsActions.minimizeModel(chatId, modelId); + }, + [chatId], + ); + + const handleToggleMinimize = useCallback( + (modelId: string) => { + if (!chatId) return; + if (chatContainerRef.current) { + pendingScrollTopRef.current = + chatContainerRef.current.scrollTop; + } + if (minimizedModels.has(modelId)) { + minimizedModelsActions.expandModel(chatId, modelId); + } else { + minimizedModelsActions.minimizeModel(chatId, modelId); + } + }, + [chatId, minimizedModels], + ); + + const handleModelStopped = useCallback((modelId: string) => { + setMovedRightModels((prev) => { + if (prev.has(modelId)) return prev; + const next = new Set(prev); + next.add(modelId); + return next; + }); + }, []); + // Scroll-to-bottom handling const [showScrollButton, setShowScrollButton] = useState(false); @@ -1773,6 +2381,16 @@ export default function MultiChat() { [chatContainerRef], ); const lastMessageSetRef = useRef<HTMLDivElement>(null); + const pendingScrollTopRef = useRef<number | null>(null); + + useLayoutEffect(() => { + if (pendingScrollTopRef.current === null) return; + const container = chatContainerRef.current; + if (container) { + container.scrollTop = pendingScrollTopRef.current; + } + pendingScrollTopRef.current = null; + }, [minimizedModels]); // Replies drawer state - controlled by replyId query parameter const repliesDrawerOpen = !!replyChatId; @@ -1809,6 +2427,45 @@ export default function MultiChat() { currentMessageSet?.selectedBlockType === "compare" ? currentMessageSet.compareBlock : undefined; + const getCompareDisplayName = useCallback( + (modelId: string) => + modelConfigsQuery.data?.find((m) => m.id === modelId) + ?.displayName ?? modelId, + [modelConfigsQuery.data], + ); + const customCompareOrder = useModelOrderStore((state) => + chatId ? state.modelOrderByChatId.get(chatId) : undefined, + ); + const currentVisualOrder = useModelOrderStore((state) => + chatId ? state.currentVisualOrderByChatId.get(chatId) : undefined, + ); + const sortedCompareMessages = useMemo(() => { + if (!currentCompareBlock) return []; + return [...currentCompareBlock.messages].sort((a, b) => { + if (customCompareOrder) { + const aIdx = customCompareOrder.indexOf(a.model); + const bIdx = customCompareOrder.indexOf(b.model); + if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx; + if (aIdx !== -1) return -1; + if (bIdx !== -1) return 1; + } + const aActive = a.state === "streaming"; + const bActive = b.state === "streaming"; + const aMoved = movedRightModels.has(a.model); + const bMoved = movedRightModels.has(b.model); + + if (aActive !== bActive) return aActive ? -1 : 1; + if (aMoved !== bMoved) return aMoved ? 1 : -1; + return getCompareDisplayName(a.model).localeCompare( + getCompareDisplayName(b.model), + ); + }); + }, [ + currentCompareBlock, + getCompareDisplayName, + movedRightModels, + customCompareOrder, + ]); // ---------------------- // Effects @@ -1985,6 +2642,8 @@ export default function MultiChat() { }, [doShareChat]); const selectMessage = MessageAPI.useSelectMessage(); + const deselectCompareMessages = MessageAPI.useDeselectCompareMessages(); + const deselectToolsMessages = MessageAPI.useDeselectToolsMessages(); const selectSynthesis = MessageAPI.useSelectSynthesis(); const setReviewsEnabled = MessageAPI.useSetReviewsEnabled(); // const nextTools = API.useNextTools(); @@ -2030,31 +2689,118 @@ export default function MultiChat() { if (e.metaKey && /^[1-8]$/.test(e.key)) { // cmd + 1-8: select message at index e.preventDefault(); - if (currentMessageSet?.selectedBlockType !== "compare") { - console.warn( - "skipping cmd+1-8 because we're not in compare mode", - ); - return; - } - // Get message at index (1-based) const index = parseInt(e.key) - 1; - if ( - !currentCompareBlock || - currentCompareBlock.messages.length <= index - ) { - console.warn( - `couldn't select message at ${index} from cmd+${index + 1}`, + if (currentMessageSet?.selectedBlockType === "compare") { + if ( + !currentCompareBlock || + sortedCompareMessages.length <= index + ) { + console.warn( + `couldn't select message at ${index} from cmd+${index + 1}`, + ); + return; + } + const compareMessage = sortedCompareMessages[index]; + if (compareMessage.selected) { + deselectCompareMessages.mutate({ + chatId: chatId!, + messageSetId: currentMessageSet.id, + }); + return; + } + const compareMessageId = compareMessage.id; + selectMessage.mutate({ + chatId: chatId!, + messageSetId: currentMessageSet.id, + messageId: compareMessageId, + blockType: "compare", + }); + document + .querySelector( + `[data-compare-message-id="${compareMessageId}"]`, + ) + ?.scrollIntoView({ + behavior: "smooth", + inline: "nearest", + block: "nearest", + }); + } else if (currentMessageSet?.selectedBlockType === "tools") { + // Use the resolved visual order so cmd+1 selects the + // leftmost column regardless of streaming completion order. + const allMsgs = currentMessageSet.toolsBlock.chatMessages; + const orderedMsgs = currentVisualOrder + ? currentVisualOrder + .map((id) => allMsgs.find((m) => m.model === id)) + .filter((m) => m !== undefined) + : allMsgs; + if (orderedMsgs.length <= index) { + console.warn( + `couldn't select message at ${index} from cmd+${index + 1}`, + ); + return; + } + const toolsMessage = orderedMsgs[index]; + if (toolsMessage.selected) { + deselectToolsMessages.mutate({ + chatId: chatId!, + messageSetId: currentMessageSet.id, + }); + return; + } + const toolsMessageId = toolsMessage.id; + selectMessage.mutate({ + chatId: chatId!, + messageSetId: currentMessageSet.id, + messageId: toolsMessageId, + blockType: "tools", + }); + document + .querySelector( + `[data-tools-message-id="${toolsMessageId}"]`, + ) + ?.scrollIntoView({ + behavior: "smooth", + inline: "nearest", + block: "nearest", + }); + } + } else if ( + (e.metaKey || e.ctrlKey) && + e.shiftKey && + e.key === " " + ) { + // cmd/ctrl + shift + space: pop selected model to first column position + e.preventDefault(); + if (!chatId || !currentMessageSet) return; + if (currentMessageSet.selectedBlockType === "tools") { + const allMsgs = currentMessageSet.toolsBlock.chatMessages; + const selectedMsg = allMsgs.find((m) => m.selected); + if (!selectedMsg) return; + const currentOrder = + currentVisualOrder ?? allMsgs.map((m) => m.model); + const rest = currentOrder.filter( + (id) => id !== selectedMsg.model, ); - return; + modelOrderActions.setModelOrder(chatId, [ + selectedMsg.model, + ...rest, + ]); + } else if (currentMessageSet.selectedBlockType === "compare") { + const selectedMsg = sortedCompareMessages.find( + (m) => m.selected, + ); + if (!selectedMsg) return; + const currentOrder = sortedCompareMessages.map( + (m) => m.model, + ); + const rest = currentOrder.filter( + (id) => id !== selectedMsg.model, + ); + modelOrderActions.setModelOrder(chatId, [ + selectedMsg.model, + ...rest, + ]); } - const message = currentCompareBlock.messages[index]; - - selectMessage.mutate({ - chatId: chatId!, - messageSetId: currentMessageSet.id, - messageId: message.id, - blockType: "compare", - }); } else if (e.metaKey && e.key === "s" && !e.shiftKey) { e.preventDefault(); if (!currentMessageSet) return; @@ -2103,16 +2849,20 @@ export default function MultiChat() { chatId, currentMessageSet, currentCompareBlock, + sortedCompareMessages, isQuickChatWindow, handleShareChat, handleOpenQuickChatInMainWindow, appMetadata, + deselectCompareMessages, + deselectToolsMessages, selectMessage, selectSynthesis, setReviewsEnabled, setVisionModeEnabled, // nextTools, handleToggleVisionMode, + currentVisualOrder, ]); const scrollToLatestMessageSet = useCallback(() => { @@ -2467,6 +3217,11 @@ export default function MultiChat() { inputRef={inputRef} setShowScrollButton={setShowScrollButton} handleScrollToBottom={handleScrollToBottom} + minimizedModels={minimizedModels} + onMinimize={handleMinimize} + onToggleMinimize={handleToggleMinimize} + movedRightModels={movedRightModels} + onModelStopped={handleModelStopped} /> <ChatInput isNewChat={chatQuery.data?.isNewChat} @@ -2480,6 +3235,7 @@ export default function MultiChat() { sentAttachmentTypes={sentAttachmentTypes} showScrollButton={showScrollButton} handleScrollToBottom={handleScrollToBottom} + minimizedModels={minimizedModels} /> </div> </ResizablePanel> @@ -2635,12 +3391,22 @@ function MainScrollableContentView({ inputRef, // used for spacing setShowScrollButton, handleScrollToBottom, + minimizedModels, + onMinimize, + onToggleMinimize, + movedRightModels, + onModelStopped, }: { chatContainerRef: React.RefObject<HTMLDivElement>; lastMessageSetRef: React.RefObject<HTMLDivElement>; inputRef: React.RefObject<HTMLTextAreaElement>; setShowScrollButton: (show: boolean) => void; handleScrollToBottom: (smooth?: boolean) => void; + minimizedModels: Set<string>; + onMinimize: (modelId: string) => void; + onToggleMinimize: (modelId: string) => void; + movedRightModels: Set<string>; + onModelStopped: (modelId: string) => void; }) { const appMetadata = useWaitForAppMetadata(); const { chatId } = useParams(); @@ -2748,6 +3514,8 @@ function MainScrollableContentView({ setShowScrollbar(false); }; + // minimizedModels and related state are lifted to MultiChat and passed as props + // early stopping if (messageSetsQuery.isPending) { return <ChatMessageSkeleton />; @@ -2771,6 +3539,11 @@ function MainScrollableContentView({ userMessageRef={undefined} isLastRow={isLastRow} isQuickChatWindow={isQuickChatWindow} + minimizedModels={minimizedModels} + onToggleMinimize={onToggleMinimize} + movedRightModels={movedRightModels} + onModelStopped={onModelStopped} + onMinimize={onMinimize} /> ); } diff --git a/src/ui/components/MultiChatDeprecationPath.tsx b/src/ui/components/MultiChatDeprecationPath.tsx index b7dae73d..62863812 100644 --- a/src/ui/components/MultiChatDeprecationPath.tsx +++ b/src/ui/components/MultiChatDeprecationPath.tsx @@ -1,8 +1,28 @@ import { useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { useParams } from "react-router-dom"; +import { motion, LayoutGroup } from "framer-motion"; +import { + DndContext, + DragOverlay, + PointerSensor, + useSensor, + useSensors, + useDraggable, + closestCenter, +} from "@dnd-kit/core"; +import type { + DragStartEvent, + DragEndEvent, + DragOverEvent, +} from "@dnd-kit/core"; +import { restrictToHorizontalAxis } from "@dnd-kit/modifiers"; +import { SortableColumnItem } from "./SortableColumnItem"; +import { useModelOrderStore } from "@core/infra/ModelOrderStore"; import { Button } from "./ui/button"; import { Maximize2Icon, + Minimize2Icon, MergeIcon, RemoveFormattingIcon, StopCircleIcon, @@ -32,6 +52,7 @@ import { Dialog, DialogContent, DialogDescription, + DialogFooter, DialogTitle, } from "./ui/dialog"; import { MessageMarkdown } from "./renderers/MessageMarkdown"; @@ -50,6 +71,9 @@ import * as Brainstorms from "@core/chorus/brainstorm"; import Markdown from "react-markdown"; import { MessageCostDisplay } from "./MessageCostDisplay"; import { Skeleton } from "./ui/skeleton"; +import { fetchSavedModelConfigChat } from "@core/chorus/api/ModelConfigChatAPI"; +import * as ModelConfigChatAPI from "@core/chorus/api/ModelConfigChatAPI"; +import { syncGlobalCompareMetadataToConfigIds } from "@core/chorus/ChatCompareSelection"; import * as ModelsAPI from "@core/chorus/api/ModelsAPI"; import { useWaitForAppMetadata } from "@ui/hooks/useWaitForAppMetadata"; import { ProviderName } from "@core/chorus/Models"; @@ -57,6 +81,8 @@ import { dialogActions } from "@core/infra/DialogStore"; import * as MessageAPI from "@core/chorus/api/MessageAPI"; import SimpleCopyButton from "./unused/CopyButton"; +type DragListeners = ReturnType<typeof useDraggable>["listeners"]; + function getReviewerLongName( model: string, allModelConfigs: Models.ModelConfig[], @@ -99,6 +125,29 @@ function getBrainstormerProvider(model: string): ProviderName { throw new Error(`Unknown brainstormer model: ${model}`); } +const PROVIDER_NAMES: ProviderName[] = [ + "anthropic", + "openai", + "google", + "perplexity", + "openrouter", + "ollama", + "lmstudio", + "grok", + "meta", +]; + +function getLegacyProviderName(model: string): ProviderName | undefined { + if (!model) return undefined; + const primaryToken = model.split("::")[0]; + const legacyProviderId = primaryToken.includes("/") + ? primaryToken.split("/")[0] + : primaryToken; + return PROVIDER_NAMES.includes(legacyProviderId as ProviderName) + ? (legacyProviderId as ProviderName) + : undefined; +} + /** * For legacy reasons, the 'model' value in the message row might not always correspond * to a valid id in the models table. @@ -289,6 +338,9 @@ function AIMessageView({ isLastRow, isQuickChatWindow, isSynthesis, + onMinimize, + onStop, + dragHandleProps, }: { message: Message; blockType: BlockType; @@ -296,6 +348,9 @@ function AIMessageView({ isLastRow?: boolean; isQuickChatWindow?: boolean; isSynthesis?: boolean; + onMinimize?: () => void; + onStop?: () => void; + dragHandleProps?: DragListeners; }) { const [raw, setRaw] = useState(false); const [streamStartTime, setStreamStartTime] = useState<Date>(); @@ -398,7 +453,9 @@ function AIMessageView({ </div> ) : ( // compare mode: model name, always visible - <div className={`ml-2 px-2.5 bg-background`}> + <div + className={`ml-2 px-2.5 bg-background flex items-center`} + > <span className="print-model-name text-sm font-[400] text-gray-800 rounded-full py-1 inline-flex items-center gap-1"> {isSynthesis ? ( <MergeIcon className="w-3 h-3 inline-block mb-0.5 mr-1" /> @@ -406,16 +463,27 @@ function AIMessageView({ modelName )} </span> - {shortcutNumber !== undefined && isLastRow && ( + {!isSynthesis && message.selected ? ( <span - className={`no-print ml-1 text-sm ${ - !message.selected - ? "text-muted-foreground/30" - : "text-muted-foreground" - }`} + {...(dragHandleProps ?? {})} + className="no-print ml-1 text-xs text-accent-600 font-geist-mono uppercase tracking-wider animate-brief-flash cursor-grab active:cursor-grabbing select-none" + // dragHandleProps are @dnd-kit listeners (onPointerDown etc.) > - ⌘{shortcutNumber} + Drag to move </span> + ) : ( + shortcutNumber !== undefined && + isLastRow && ( + <span + className={`no-print ml-1 text-sm ${ + !message.selected + ? "text-muted-foreground/30" + : "text-muted-foreground" + }`} + > + ⌘{shortcutNumber} + </span> + ) )} </div> )} @@ -435,6 +503,7 @@ function AIMessageView({ chatId: message.chatId, messageId: message.id, }); + onStop?.(); }} > <StopCircleIcon className="w-3.5 h-3.5" /> @@ -497,6 +566,25 @@ function AIMessageView({ </TooltipTrigger> <TooltipContent>Open full screen</TooltipContent> </Tooltip> + {onMinimize && ( + <Tooltip> + <TooltipTrigger asChild> + <button + className="hover:text-foreground" + onClick={(e) => { + e.stopPropagation(); + onMinimize(); + }} + > + <Minimize2Icon + strokeWidth={1.5} + className="w-3.5 h-3.5" + /> + </button> + </TooltipTrigger> + <TooltipContent>Minimize</TooltipContent> + </Tooltip> + )} </div> </div> @@ -664,152 +752,475 @@ function SynthesisAnimation() { ); } +function MinimizedColumnView({ + message, + onExpand, +}: { + message: Message; + onExpand: () => void; +}) { + const [retryRequested, setRetryRequested] = useState(false); + const modelConfigsQuery = ModelsAPI.useModelConfigs(); + const modelConfigQuery = ModelsAPI.useModelConfig(message.model); + const restartMessage = MessageAPI.useRestartMessageLegacy( + message.chatId, + message.messageSetId, + message.id, + ); + const modelName = getMessageModelName( + message.model, + modelConfigsQuery.data ?? [], + ); + const modelConfig = modelConfigsQuery.data?.find( + (m) => m.id === message.model, + ); + const modelId = modelConfig?.modelId; + const providerName = modelId + ? Models.getProviderName(modelId) + : getLegacyProviderName(message.model); + const failureDialogId = `minimized-failure-${message.id}`; + + const didNotReturnResponse = + message.state === "idle" && + !message.text.trim() && + !message.errorMessage; + const hasFailed = Boolean(message.errorMessage) || didNotReturnResponse; + const isRetrying = + retryRequested || + restartMessage.isPending || + message.state === "streaming"; + const failureMessage = + message.errorMessage ?? "Model did not return a response."; + + useEffect(() => { + // Once regeneration starts producing output, restore the full column. + if ( + retryRequested && + (message.state === "streaming" || message.text.trim().length > 0) + ) { + setRetryRequested(false); + onExpand(); + } + }, [message.state, message.text, onExpand, retryRequested]); + + useEffect(() => { + if (retryRequested && restartMessage.isError) { + setRetryRequested(false); + } + }, [retryRequested, restartMessage.isError]); + + return ( + <> + <button + onClick={() => { + if (hasFailed && !isRetrying) { + dialogActions.openDialog(failureDialogId); + return; + } + onExpand(); + }} + className="group/minimized flex flex-col items-center gap-2 w-10 pt-2 pb-4 rounded-md border-[0.090rem] hover:bg-accent/50 transition-colors cursor-pointer" + > + {providerName && ( + <ProviderLogo size="sm" provider={providerName} /> + )} + {isRetrying && <RetroSpinner />} + {!isRetrying && hasFailed && ( + <CircleAlertIcon className="w-3 h-3 text-destructive" /> + )} + <span + className="text-xs text-muted-foreground max-h-[120px] overflow-hidden" + style={{ writingMode: "vertical-rl" }} + > + {modelName} + </span> + <Maximize2Icon className="w-3 h-3 text-muted-foreground opacity-0 group-hover/minimized:opacity-100 transition-opacity" /> + </button> + + <Dialog id={failureDialogId}> + <DialogContent className="max-w-md p-4"> + <DialogTitle className="text-lg">Model failed</DialogTitle> + <DialogDescription className="text-sm whitespace-pre-wrap"> + {failureMessage} + </DialogDescription> + <DialogFooter className="pt-2"> + <Button + variant="outline" + onClick={() => + dialogActions.closeDialog(failureDialogId) + } + > + Close + </Button> + <Button + disabled={ + !modelConfigQuery.data || + restartMessage.isPending || + message.state === "streaming" + } + onClick={() => { + if (!modelConfigQuery.data) return; + restartMessage.reset(); + setRetryRequested(true); + dialogActions.closeDialog(failureDialogId); + restartMessage.mutate( + { + modelConfig: modelConfigQuery.data, + }, + { + onSuccess: (streamingToken) => { + if (!streamingToken) { + setRetryRequested(false); + } + }, + onError: () => { + setRetryRequested(false); + }, + }, + ); + }} + > + {restartMessage.isPending ? ( + <> + <RetroSpinner className="mr-2" /> + Regenerating + </> + ) : ( + "Regenerate response" + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </> + ); +} + function CompareBlockView({ messageSetId, compareBlock, isLastRow = false, isQuickChatWindow, + minimizedModels, + onToggleMinimize, + movedRightModels, + onModelStopped, }: { messageSetId: string; compareBlock: CompareBlock; isLastRow: boolean; isQuickChatWindow: boolean; + minimizedModels: Set<string>; + onToggleMinimize: (modelId: string) => void; + movedRightModels: Set<string>; + onModelStopped: (modelId: string) => void; }) { const { chatId } = useParams(); + const queryClient = useQueryClient(); const addMessageToCompareBlock = MessageAPI.useAddMessageToCompareBlock( chatId!, ); - const addModelToCompareConfigs = MessageAPI.useAddModelToCompareConfigs(); + const appendModelToChatCompare = + ModelConfigChatAPI.useAppendModelConfigToChatCompare(chatId!); + const modelConfigsQuery = ModelsAPI.useModelConfigs(); + + const getDisplayName = (modelId: string) => + modelConfigsQuery.data?.find((m) => m.id === modelId)?.displayName ?? + modelId; + + const customOrder = useModelOrderStore((state) => + chatId ? state.modelOrderByChatId.get(chatId) : undefined, + ); + const setModelOrder = useModelOrderStore((state) => state.setModelOrder); + + // Sort: custom order when set, else streaming first → non-moved-right → alphabetical + const sortedMessages = [...compareBlock.messages].sort((a, b) => { + if (customOrder) { + const aIdx = customOrder.indexOf(a.model); + const bIdx = customOrder.indexOf(b.model); + if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx; + if (aIdx !== -1) return -1; + if (bIdx !== -1) return 1; + } + const aActive = a.state === "streaming"; + const bActive = b.state === "streaming"; + const aMoved = movedRightModels.has(a.model); + const bMoved = movedRightModels.has(b.model); + + if (aActive !== bActive) return aActive ? -1 : 1; + if (aMoved !== bMoved) return aMoved ? 1 : -1; + return getDisplayName(a.model).localeCompare(getDisplayName(b.model)); + }); - const aiMessages = compareBlock.messages; const synthesisMessage = compareBlock.synthesis; const isSynthesisSelected = synthesisMessage?.selected ?? false; - const aiMessagesToDisplay = [ - ...(synthesisMessage && synthesisMessage.selected - ? [synthesisMessage] - : []), - ...aiMessages, - ]; const selectSynthesis = MessageAPI.useSelectSynthesis(); const deselectSynthesis = MessageAPI.useDeselectSynthesis(); const handleAddModel = (modelId: string) => { - // First add the model to the selected models list - addModelToCompareConfigs.mutate({ - newSelectedModelConfigId: modelId, - }); - // Then add it to the current message set - addMessageToCompareBlock.mutate({ - messageSetId, - modelId, - }); + void (async () => { + try { + await appendModelToChatCompare.mutateAsync(modelId); + const ids = (await fetchSavedModelConfigChat(chatId!)) ?? []; + await syncGlobalCompareMetadataToConfigIds( + ids, + modelConfigsQuery.data ?? [], + ); + void queryClient.invalidateQueries( + ModelsAPI.modelConfigQueries.compare(), + ); + addMessageToCompareBlock.mutate({ + messageSetId, + modelId, + }); + if (chatId) { + const current = + customOrder ?? sortedMessages.map((m) => m.model); + setModelOrder(chatId, [...current, modelId]); + } + } catch (error) { + console.error("Failed to add model to chat compare", error); + } + })(); }; - function renderMessage(message: Message, index: number) { - const shortcutNumber = isLastRow ? index + 1 : undefined; + const [activeDragId, setActiveDragId] = useState<string | null>(null); + const [overId, setOverId] = useState<string | null>(null); + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }), + ); - return ( - <div - key={message.id} - className={`mr-2 ${isQuickChatWindow ? "pt-0" : "pt-2"} ${ - isQuickChatWindow - ? "" - : "flex-1 w-full min-w-[400px] max-w-[550px]" - } w-full max-w-prose`} - > - <AIMessageView - message={message} - blockType="compare" - shortcutNumber={shortcutNumber} - isLastRow={isLastRow} - isQuickChatWindow={isQuickChatWindow} - isSynthesis={message.model === "chorus::synthesize"} - /> - </div> - ); + function onDragStart({ active }: DragStartEvent) { + setActiveDragId(active.id as string); + } + + function onDragOver({ over }: DragOverEvent) { + setOverId(over ? (over.id as string) : null); } + function onDragEnd({ active, over }: DragEndEvent) { + setActiveDragId(null); + setOverId(null); + if (!over || active.id === over.id) return; + const oldIndex = sortedMessages.findIndex((m) => m.model === active.id); + const newIndex = sortedMessages.findIndex((m) => m.model === over.id); + if (oldIndex === -1 || newIndex === -1) return; + const newOrder = sortedMessages.map((m) => m.model); + newOrder.splice(oldIndex, 1); + newOrder.splice(newIndex, 0, active.id as string); + if (chatId) setModelOrder(chatId, newOrder); + } + + // Total visible items for shortcut numbering: synthesis (if shown) + model columns + const synthesisShortcut = isLastRow ? 1 : undefined; + const modelShortcutOffset = isLastRow + ? isSynthesisSelected + ? 2 + : 1 + : undefined; + + const totalVisibleCount = + (isSynthesisSelected ? 1 : 0) + sortedMessages.length; + const compareItemOrder = sortedMessages.map((m) => m.model); + return ( - <div - className={`flex w-full h-fit pb-2 ${ - // get horizontal scroll bars, plus hackily disable y scrolling - // because we're seeing scroll bars when we shouldn't - "overflow-x-auto scrollbar-only-on-hover overflow-y-hidden" - }`} - > - <div className="flex-none w-10 mt-1"> - {isLastRow && aiMessagesToDisplay.length > 1 && ( - <Tooltip> - {/* synthesis button */} - <TooltipTrigger asChild> - {isSynthesisSelected ? ( - <button - className="text-sm h-7 w-7 rounded-full bg-badge hover:bg-accent flex items-center justify-center" - onClick={() => { - deselectSynthesis.mutate({ - chatId: chatId!, - messageSetId, - }); - }} - > - <SplitIcon className="w-3 h-3" /> - </button> - ) : ( - <button - className="text-sm h-7 w-7 rounded-full bg-badge hover:bg-accent flex items-center justify-center" - onClick={() => { - selectSynthesis.mutate({ - chatId: chatId!, - messageSetId, - }); - }} + <LayoutGroup id={`compare-${messageSetId}`}> + <div + className={`flex w-full h-fit pb-2 ${ + // get horizontal scroll bars, plus hackily disable y scrolling + // because we're seeing scroll bars when we shouldn't + "overflow-x-auto scrollbar-only-on-hover overflow-y-hidden" + }`} + > + <div className="flex-none w-10 mt-1"> + {isLastRow && totalVisibleCount > 1 && ( + <Tooltip> + {/* synthesis button */} + <TooltipTrigger asChild> + {isSynthesisSelected ? ( + <button + className="text-sm h-7 w-7 rounded-full bg-badge hover:bg-accent flex items-center justify-center" + onClick={() => { + deselectSynthesis.mutate({ + chatId: chatId!, + messageSetId, + }); + }} + > + <SplitIcon className="w-3 h-3" /> + </button> + ) : ( + <button + className="text-sm h-7 w-7 rounded-full bg-badge hover:bg-accent flex items-center justify-center" + onClick={() => { + selectSynthesis.mutate({ + chatId: chatId!, + messageSetId, + }); + }} + > + <MergeIcon className="w-3 h-3" /> + </button> + )} + </TooltipTrigger> + <TooltipContent side="top" align="start"> + {isSynthesisSelected + ? "Revert to original responses" + : "Synthesize replies into a single message (⌘S)"} + </TooltipContent> + </Tooltip> + )} + </div> + + {/* Synthesis message (pinned, not draggable) */} + {synthesisMessage && isSynthesisSelected && ( + <motion.div + key={synthesisMessage.id} + layout + layoutId={`compare-col-${synthesisMessage.model}-${messageSetId}`} + transition={{ duration: 0.3, ease: "easeInOut" }} + className={`mr-2 ${isQuickChatWindow ? "pt-0" : "pt-2"} w-full max-w-prose`} + > + <AIMessageView + message={synthesisMessage} + blockType="compare" + shortcutNumber={synthesisShortcut} + isLastRow={isLastRow} + isQuickChatWindow={isQuickChatWindow} + isSynthesis={true} + /> + </motion.div> + )} + + {/* Draggable model columns */} + <DndContext + sensors={sensors} + collisionDetection={closestCenter} + modifiers={[restrictToHorizontalAxis]} + onDragStart={onDragStart} + onDragOver={onDragOver} + onDragEnd={onDragEnd} + > + <div className="flex"> + {sortedMessages.map((message, index) => { + const isMinimized = minimizedModels.has( + message.model, + ); + const shortcutNumber = + modelShortcutOffset !== undefined + ? modelShortcutOffset + index + : undefined; + + return ( + <motion.div + key={message.model} + layout + layoutId={`compare-col-${message.model}-${messageSetId}`} + data-compare-message-id={message.id} > - <MergeIcon className="w-3 h-3" /> - </button> - )} - </TooltipTrigger> - <TooltipContent side="top" align="start"> - {isSynthesisSelected - ? "Revert to original responses" - : "Synthesize replies into a single message (⌘S)"} - </TooltipContent> - </Tooltip> + <SortableColumnItem + id={message.model} + disabled={ + !message.selected || isMinimized + } + activeDragId={activeDragId} + overId={overId} + itemOrder={compareItemOrder} + className={`mr-2 ${ + isQuickChatWindow ? "pt-0" : "pt-2" + } ${ + isMinimized + ? "flex-none" + : isQuickChatWindow + ? "" + : "flex-1 w-full min-w-[400px] max-w-[550px]" + } w-full max-w-prose`} + > + {(listeners) => + isMinimized ? ( + <MinimizedColumnView + message={message} + onExpand={() => + onToggleMinimize( + message.model, + ) + } + /> + ) : ( + <AIMessageView + message={message} + blockType="compare" + shortcutNumber={ + shortcutNumber + } + isLastRow={isLastRow} + isQuickChatWindow={ + isQuickChatWindow + } + isSynthesis={false} + onMinimize={() => + onToggleMinimize( + message.model, + ) + } + onStop={() => + onModelStopped( + message.model, + ) + } + dragHandleProps={listeners} + /> + ) + } + </SortableColumnItem> + </motion.div> + ); + })} + </div> + <DragOverlay> + {activeDragId && ( + <div className="bg-background border rounded-md shadow-lg px-4 py-2 cursor-grabbing opacity-90"> + <span className="text-sm"> + {getDisplayName(activeDragId)} + </span> + </div> + )} + </DragOverlay> + </DndContext> + + {isLastRow && ( + <> + <button + className="w-14 flex-none text-sm text-muted-foreground rounded-md border-[0.090rem] py-[0.6rem] px-2 mt-2 h-fit hover:bg-accent" + onClick={() => { + dialogActions.openDialog( + MANAGE_MODELS_COMPARE_INLINE_DIALOG_ID, + ); + }} + > + <div className="flex flex-col items-center gap-1"> + <PlusIcon className="w-3 h-3" /> + Add + </div> + </button> + + {/* Add Model box (can go basically anywhere, but shouldn't be inside the button) */} + <ManageModelsBox + id={MANAGE_MODELS_COMPARE_INLINE_DIALOG_ID} + mode={{ + type: "add", + checkedModelConfigIds: + compareBlock.messages.map((m) => m.model), + onAddModel: handleAddModel, + }} + /> + </> )} </div> - {aiMessagesToDisplay.map((message, index) => { - return renderMessage(message, index); - })} - {isLastRow && ( - <> - <button - className="w-14 flex-none text-sm text-muted-foreground rounded-md border-[0.090rem] py-[0.6rem] px-2 mt-2 h-fit hover:bg-accent" - onClick={() => { - dialogActions.openDialog( - MANAGE_MODELS_COMPARE_INLINE_DIALOG_ID, - ); - }} - > - <div className="flex flex-col items-center gap-1"> - <PlusIcon className="w-3 h-3" /> - Add - </div> - </button> - - {/* Add Model box (can go basically anywhere, but shouldn't be inside the button) */} - <ManageModelsBox - id={MANAGE_MODELS_COMPARE_INLINE_DIALOG_ID} - mode={{ - type: "add", - checkedModelConfigIds: aiMessages.map( - (m) => m.model, - ), - onAddModel: handleAddModel, - }} - /> - </> - )} - </div> + </LayoutGroup> ); } diff --git a/src/ui/components/ProjectView.tsx b/src/ui/components/ProjectView.tsx index 2c96e7ff..676c0461 100644 --- a/src/ui/components/ProjectView.tsx +++ b/src/ui/components/ProjectView.tsx @@ -26,6 +26,13 @@ import { DialogHeader, DialogTitle, } from "./ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./ui/select"; import { AttachmentDropArea } from "./AttachmentsViews"; import _ from "lodash"; import AutoExpandingTextarea from "./AutoExpandingTextarea"; @@ -48,9 +55,12 @@ import { dialogActions, useDialogStore } from "@core/infra/DialogStore"; import { useSettings } from "./hooks/useSettings"; import { Link } from "react-router-dom"; import { SidebarTrigger } from "./ui/sidebar"; +import { usePromptProfiles } from "@core/chorus/api/PromptProfilesAPI"; import * as ProjectAPI from "@core/chorus/api/ProjectAPI"; import * as ChatAPI from "@core/chorus/api/ChatAPI"; +const NONE = "__none__"; + const deleteProjectDialogId = (projectId: string) => `delete-project-dialog-${projectId}`; @@ -72,6 +82,11 @@ export default function ProjectView() { const deleteProject = ProjectAPI.useDeleteProject(); const getOrCreateNewChat = ChatAPI.useGetOrCreateNewChat(); const setMagicProjectsEnabled = ProjectAPI.useSetMagicProjectsEnabled(); + const setProjectDefaultPromptProfile = + ProjectAPI.useSetProjectDefaultPromptProfile(); + + // Queries + const { data: promptProfiles } = usePromptProfiles(); // Queries const projectsQuery = useQuery(ProjectAPI.projectQueries.list()); @@ -423,6 +438,42 @@ export default function ProjectView() { disabled={project.isImported} /> </div> + {(promptProfiles?.length ?? 0) > 0 && ( + <div className="flex justify-between items-center gap-2 bg-muted px-3 py-2 rounded mt-1"> + <div className="flex flex-col gap-1 min-w-0"> + <h2 className="font-medium text-sm"> + Default Prompt Profile + </h2> + <p className="text-xs text-muted-foreground font-[350] -mt-0.5"> + Applied to new chats in this project, + overriding the global default. + </p> + </div> + <Select + value={project.defaultPromptProfileId ?? NONE} + onValueChange={(v) => { + void setProjectDefaultPromptProfile.mutateAsync( + { + projectId, + profileId: v === NONE ? null : v, + }, + ); + }} + > + <SelectTrigger className="w-36 h-7 text-xs shrink-0"> + <SelectValue placeholder="None" /> + </SelectTrigger> + <SelectContent> + <SelectItem value={NONE}>None</SelectItem> + {(promptProfiles ?? []).map((p) => ( + <SelectItem key={p.id} value={p.id}> + {p.name} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + )} <div className=""> {/* Magic context details */} <div className="space-y-2 mt-1 max-h-[400px] overflow-y-auto"> diff --git a/src/ui/components/PromptProfilePill.tsx b/src/ui/components/PromptProfilePill.tsx new file mode 100644 index 00000000..cb4f85a2 --- /dev/null +++ b/src/ui/components/PromptProfilePill.tsx @@ -0,0 +1,121 @@ +import { useState } from "react"; +import { UserCircle, Check, Settings } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; +import { + useChatPromptProfile, + usePromptProfiles, + useSetChatPromptProfile, +} from "@core/chorus/api/PromptProfilesAPI"; +import { dialogActions } from "@core/infra/DialogStore"; +import { SETTINGS_DIALOG_ID } from "./Settings"; + +export function PromptProfilePill({ chatId }: { chatId: string }) { + const [open, setOpen] = useState(false); + const activeProfile = useChatPromptProfile(chatId); + const { data: profiles } = usePromptProfiles(); + const setProfile = useSetChatPromptProfile(); + + const handleSelect = (profileId: string | null) => { + setProfile.mutate({ chatId, profileId }); + setOpen(false); + }; + + const handleManage = () => { + setOpen(false); + dialogActions.openDialog(SETTINGS_DIALOG_ID); + }; + + return ( + <Popover open={open} onOpenChange={setOpen}> + {activeProfile ? ( + <PopoverTrigger asChild> + <button + className="inline-flex bg-muted items-center justify-center rounded-full h-7 pl-2 text-sm hover:bg-muted/80 px-3 py-1 ring-offset-background focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 flex-shrink-0 gap-1.5 max-w-[12rem] min-w-0 overflow-hidden" + aria-label={`Prompt profile: ${activeProfile.name}`} + > + {activeProfile.icon ? ( + <span className="text-xs leading-none"> + {activeProfile.icon} + </span> + ) : ( + <UserCircle className="w-3 h-3" /> + )} + <span className="truncate">{activeProfile.name}</span> + </button> + </PopoverTrigger> + ) : ( + <Tooltip> + <TooltipTrigger asChild> + <PopoverTrigger asChild> + <button + className="inline-flex bg-muted items-center justify-center rounded-full h-7 w-7 text-sm hover:bg-muted/80 ring-offset-background focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 flex-shrink-0 text-muted-foreground" + aria-label="Set prompt profile" + > + <UserCircle className="w-3.5 h-3.5" /> + </button> + </PopoverTrigger> + </TooltipTrigger> + <TooltipContent>Set prompt profile</TooltipContent> + </Tooltip> + )} + <PopoverContent + className="w-64 p-2" + align="start" + side="top" + sideOffset={8} + > + <div className="space-y-1"> + <div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide"> + Prompt Profile + </div> + + {/* None option */} + <button + className="w-full flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted text-left" + onClick={() => handleSelect(null)} + > + <span className="w-4 flex-shrink-0"> + {!activeProfile && ( + <Check className="w-3.5 h-3.5 text-primary" /> + )} + </span> + <span className="text-muted-foreground">None</span> + </button> + + {profiles?.map((p) => ( + <button + key={p.id} + className="w-full flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted text-left" + onClick={() => handleSelect(p.id)} + > + <span className="w-4 flex-shrink-0"> + {activeProfile?.id === p.id && ( + <Check className="w-3.5 h-3.5 text-primary" /> + )} + </span> + <span className="flex items-center gap-1.5 min-w-0"> + {p.icon && ( + <span className="text-xs flex-shrink-0"> + {p.icon} + </span> + )} + <span className="truncate">{p.name}</span> + </span> + </button> + ))} + + <div className="border-t mt-1 pt-1"> + <button + className="w-full flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted text-left text-muted-foreground" + onClick={handleManage} + > + <Settings className="w-3.5 h-3.5 flex-shrink-0" /> + <span>Manage profiles...</span> + </button> + </div> + </div> + </PopoverContent> + </Popover> + ); +} diff --git a/src/ui/components/PromptProfilesTab.tsx b/src/ui/components/PromptProfilesTab.tsx new file mode 100644 index 00000000..c1117679 --- /dev/null +++ b/src/ui/components/PromptProfilesTab.tsx @@ -0,0 +1,244 @@ +import { useState } from "react"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { Textarea } from "./ui/textarea"; +import { + usePromptProfiles, + useCreatePromptProfile, + useUpdatePromptProfile, + useDeletePromptProfile, +} from "@core/chorus/api/PromptProfilesAPI"; +import { PromptProfile } from "@core/chorus/Models"; +import { Loader2, Plus, Trash2, Pencil, Check, X } from "lucide-react"; + +function EditProfileForm({ + profile, + onSave, + onCancel, +}: { + profile: PromptProfile; + onSave: (name: string, systemPrompt: string, icon: string) => void; + onCancel: () => void; +}) { + const [name, setName] = useState(profile.name); + const [systemPrompt, setSystemPrompt] = useState(profile.systemPrompt); + const [icon, setIcon] = useState(profile.icon ?? ""); + + return ( + <div className="border rounded-lg p-4 space-y-3 bg-muted/30"> + <div className="flex gap-2"> + <Input + placeholder="Icon (emoji)" + value={icon} + onChange={(e) => setIcon(e.target.value)} + className="w-24 flex-shrink-0" + /> + <Input + placeholder="Profile name" + value={name} + onChange={(e) => setName(e.target.value)} + className="flex-1" + /> + </div> + <Textarea + placeholder="System prompt — describe the persona or role..." + value={systemPrompt} + onChange={(e) => setSystemPrompt(e.target.value)} + rows={5} + className="resize-none" + /> + <div className="flex gap-2"> + <Button + size="sm" + onClick={() => onSave(name, systemPrompt, icon)} + disabled={!name.trim() || !systemPrompt.trim()} + > + <Check className="w-3.5 h-3.5 mr-1" /> + Save + </Button> + <Button size="sm" variant="ghost" onClick={onCancel}> + <X className="w-3.5 h-3.5 mr-1" /> + Cancel + </Button> + </div> + </div> + ); +} + +export function PromptProfilesTab() { + const { data: profiles, isLoading } = usePromptProfiles(); + const createProfile = useCreatePromptProfile(); + const updateProfile = useUpdatePromptProfile(); + const deleteProfile = useDeletePromptProfile(); + + const [isCreating, setIsCreating] = useState(false); + const [newName, setNewName] = useState(""); + const [newSystemPrompt, setNewSystemPrompt] = useState(""); + const [newIcon, setNewIcon] = useState(""); + const [editingId, setEditingId] = useState<string | null>(null); + + if (isLoading) { + return ( + <div className="flex items-center justify-center h-full"> + <Loader2 className="w-8 h-8 animate-spin text-muted-foreground" /> + </div> + ); + } + + const handleCreate = () => { + createProfile.mutate({ + name: newName, + systemPrompt: newSystemPrompt, + icon: newIcon || undefined, + }); + setIsCreating(false); + setNewName(""); + setNewSystemPrompt(""); + setNewIcon(""); + }; + + const handleUpdate = ( + id: string, + name: string, + systemPrompt: string, + icon: string, + ) => { + updateProfile.mutate({ + id, + name, + systemPrompt, + icon: icon || undefined, + }); + setEditingId(null); + }; + + return ( + <div className="space-y-8 max-w-2xl"> + <div> + <h2 className="text-2xl font-semibold mb-2">Prompt Profiles</h2> + <p className="text-sm text-muted-foreground"> + Prompt profiles inject a persona or role into your chats. + Select a profile from the chat input toolbar to activate it. + </p> + </div> + + <Button + onClick={() => { + setIsCreating(true); + setEditingId(null); + }} + disabled={isCreating} + > + <Plus className="w-4 h-4 mr-2" /> + Create New Profile + </Button> + + {isCreating && ( + <div className="border rounded-lg p-4 space-y-3"> + <div className="flex gap-2"> + <Input + placeholder="Icon (emoji)" + value={newIcon} + onChange={(e) => setNewIcon(e.target.value)} + className="w-24 flex-shrink-0" + /> + <Input + placeholder="Profile name" + value={newName} + onChange={(e) => setNewName(e.target.value)} + className="flex-1" + /> + </div> + <Textarea + placeholder="System prompt — describe the persona or role..." + value={newSystemPrompt} + onChange={(e) => setNewSystemPrompt(e.target.value)} + rows={5} + className="resize-none" + /> + <div className="flex gap-2"> + <Button + onClick={handleCreate} + disabled={ + !newName.trim() || !newSystemPrompt.trim() + } + > + Save + </Button> + <Button + variant="ghost" + onClick={() => { + setIsCreating(false); + setNewName(""); + setNewSystemPrompt(""); + setNewIcon(""); + }} + > + Cancel + </Button> + </div> + </div> + )} + + <div className="space-y-4"> + {profiles?.map((p) => + editingId === p.id ? ( + <EditProfileForm + key={p.id} + profile={p} + onSave={(name, systemPrompt, icon) => + handleUpdate(p.id, name, systemPrompt, icon) + } + onCancel={() => setEditingId(null)} + /> + ) : ( + <div + key={p.id} + className="border rounded-lg p-4 flex items-start justify-between gap-4" + > + <div className="min-w-0 flex-1"> + <div className="flex items-center gap-2 mb-1"> + {p.icon && ( + <span className="text-base"> + {p.icon} + </span> + )} + <h3 className="font-semibold">{p.name}</h3> + {p.author === "system" && ( + <span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> + Built-in + </span> + )} + </div> + <p className="text-sm text-muted-foreground line-clamp-2"> + {p.systemPrompt} + </p> + </div> + <div className="flex items-center gap-1 flex-shrink-0"> + <Button + variant="ghost" + size="sm" + onClick={() => { + setEditingId(p.id); + setIsCreating(false); + }} + > + <Pencil className="w-4 h-4" /> + </Button> + <Button + variant="ghost" + size="sm" + onClick={() => + deleteProfile.mutate({ id: p.id }) + } + > + <Trash2 className="w-4 h-4 text-destructive" /> + </Button> + </div> + </div> + ), + )} + </div> + </div> + ); +} diff --git a/src/ui/components/QuickChatModelSelector.tsx b/src/ui/components/QuickChatModelSelector.tsx index eb55dce0..5137d56c 100644 --- a/src/ui/components/QuickChatModelSelector.tsx +++ b/src/ui/components/QuickChatModelSelector.tsx @@ -17,9 +17,10 @@ import { useCallback, useState } from "react"; import { usePostHog } from "posthog-js/react"; import { hasApiKey } from "@core/utilities/ProxyUtils"; import { useMemo } from "react"; -import { ALLOWED_MODEL_IDS_FOR_QUICK_CHAT } from "@ui/lib/models"; import * as ModelsAPI from "@core/chorus/api/ModelsAPI"; import * as AppMetadataAPI from "@core/chorus/api/AppMetadataAPI"; +import { useProviderVisibilityMap } from "@core/chorus/api/ProviderVisibilityAPI"; +import { getFilteredModelConfigs } from "@core/utilities/ModelFiltering"; interface ModelSelectorProps { onModelSelect: (modelId: string) => void; @@ -78,17 +79,22 @@ export function QuickChatModelSelector({ [apiKeys], ); + const providerVisibilityMap = useProviderVisibilityMap(); + const quickChatSelectableModelConfigs = useMemo( () => - modelConfigsQuery?.data?.filter( + getFilteredModelConfigs( + modelConfigsQuery?.data ?? [], + providerVisibilityMap, + null, // Active profile not applied to ambient chat + ).filter( (config) => config.isEnabled && !config.id.includes("chorus") && !config.displayName.includes("Deprecated") && - ALLOWED_MODEL_IDS_FOR_QUICK_CHAT.includes(config.id) && isModelAllowed(config), ) ?? [], - [modelConfigsQuery, isModelAllowed], + [modelConfigsQuery, isModelAllowed, providerVisibilityMap], ); const handleModelSelect = useCallback( diff --git a/src/ui/components/Settings.tsx b/src/ui/components/Settings.tsx index f3a7e363..8bb0dd2c 100644 --- a/src/ui/components/Settings.tsx +++ b/src/ui/components/Settings.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Select, SelectContent, @@ -28,7 +28,6 @@ import { Plus, ExternalLinkIcon, LinkIcon, - Fullscreen, ShieldCheckIcon, } from "lucide-react"; import { @@ -40,6 +39,10 @@ import { Import, BookOpen, Globe, + Eye, + Layers, + UserCircle, + SlidersHorizontal, } from "lucide-react"; import { toast } from "sonner"; import { config } from "@core/config"; @@ -57,7 +60,6 @@ import ApiKeysForm from "./ApiKeysForm"; import Database from "@tauri-apps/plugin-sql"; import { Input } from "./ui/input"; import { Textarea } from "./ui/textarea"; -import { relaunch } from "@tauri-apps/plugin-process"; import { useDatabase } from "@ui/hooks/useDatabase"; import { Collapsible, @@ -65,7 +67,6 @@ import { CollapsibleTrigger, } from "@ui/components/ui/collapsible"; import { InfoCircledIcon } from "@radix-ui/react-icons"; -import { AccessibilitySettings } from "./AccessibilityCheck"; import { UNIVERSAL_SYSTEM_PROMPT_DEFAULT } from "@core/chorus/prompts/prompts"; import { CustomToolsetConfig, getEnvFromJSON } from "@core/chorus/Toolsets"; import * as ToolsetsAPI from "@core/chorus/api/ToolsetsAPI"; @@ -79,15 +80,20 @@ import { SiStripe } from "react-icons/si"; import { SiElevenlabs } from "react-icons/si"; import { ToolsetsManager } from "@core/chorus/ToolsetsManager"; import { getToolsetIcon } from "@core/chorus/Toolsets"; -import ShortcutRecorder from "./ShortcutRecorder"; import FeedbackButton from "./FeedbackButton"; import { SiOpenai } from "react-icons/si"; import ImportChatDialog from "./ImportChatDialog"; import { dialogActions } from "@core/infra/DialogStore"; import * as AppMetadataAPI from "@core/chorus/api/AppMetadataAPI"; import { PermissionsTab } from "./PermissionsTab"; +import { useModelConfigs } from "@core/chorus/api/ModelsAPI"; import { cn } from "@ui/lib/utils"; +import { VisibleModelsTab } from "./VisibleModelsTab"; +import { ModelProfilesTab } from "./ModelProfilesTab"; +import { PromptProfilesTab } from "./PromptProfilesTab"; +import { DefaultsTab } from "./DefaultsTab"; + type ToolsetFormProps = { toolset: CustomToolsetConfig; errors: Record<string, string>; @@ -1095,7 +1101,10 @@ export type SettingsTabId = | "import" | "system-prompt" | "api-keys" - | "quick-chat" + | "visible-models" + | "model-profiles" + | "prompt-profiles" + | "defaults" | "connections" | "permissions" | "base-url" @@ -1111,30 +1120,47 @@ const TABS: Record<SettingsTabId, TabConfig> = { import: { label: "Import", icon: Import }, "system-prompt": { label: "System Prompt", icon: FileText }, "api-keys": { label: "API Keys", icon: Key }, - "quick-chat": { label: "Ambient Chat", icon: Fullscreen }, + "visible-models": { label: "Visible Models", icon: Eye }, + "model-profiles": { label: "Model Profiles", icon: Layers }, + "prompt-profiles": { label: "Prompt Profiles", icon: UserCircle }, + defaults: { label: "Defaults", icon: SlidersHorizontal }, connections: { label: "Connections", icon: PlugIcon }, permissions: { label: "Tool Permissions", icon: ShieldCheckIcon }, "base-url": { label: "Base URL", icon: Globe }, docs: { label: "Documentation", icon: BookOpen }, } as const; -interface QuickChatSettings { - enabled: boolean; - modelConfigId?: string; - shortcut?: string; +function isSettingsTabId(tab: string): tab is SettingsTabId { + return Object.prototype.hasOwnProperty.call(TABS, tab); } +/** Sidebar order (explicit — Record iteration order is not the product source of truth). */ +const SETTINGS_TAB_ORDER: SettingsTabId[] = [ + "general", + "import", + "api-keys", + "system-prompt", + "prompt-profiles", + "visible-models", + "model-profiles", + "defaults", + "connections", + "permissions", + "base-url", + "docs", +]; + interface Settings { apiKeys: Record<string, string>; sansFont?: string; monoFont?: string; autoConvertLongText: boolean; showCost: boolean; - quickChat: QuickChatSettings; lmStudioBaseUrl?: string; autoScrapeUrls: boolean; cautiousEnter?: boolean; customToolsets?: CustomToolsetConfig[]; + titleGenerationModelConfigId?: string; } export default function Settings({ tab = "general" }: SettingsProps) { @@ -1147,10 +1173,40 @@ export default function Settings({ tab = "general" }: SettingsProps) { const [showCost, setShowCost] = useState(false); const { db } = useDatabase(); const [searchParams] = useSearchParams(); - const defaultTab = - tab || (searchParams.get("tab") as SettingsTabId) || "general"; - const [quickChatEnabled, setQuickChatEnabled] = useState(true); - const [quickChatShortcut, setQuickChatShortcut] = useState("Alt+Space"); + // Resolve the URL param first; redirect legacy "quick-chat" to "defaults". + const tabParam = searchParams.get("tab"); + const resolvedTabParam = + tabParam === "quick-chat" + ? "defaults" + : tabParam && isSettingsTabId(tabParam) + ? tabParam + : null; + const normalizedTab = tab ?? resolvedTabParam; + const defaultTab = normalizedTab ?? "general"; + const [titleGenerationModelConfigId, setTitleGenerationModelConfigId] = + useState<string | undefined>(undefined); + const modelConfigsQuery = useModelConfigs(); + const cheapOpenRouterModelOptions = useMemo( + () => + (modelConfigsQuery.data ?? []) + .filter( + (c) => + c.modelId.startsWith("openrouter::") && + c.isEnabled && + !c.isInternal && + !c.isDeprecated, + ) + .sort((a, b) => { + const priceA = + (a.promptPricePerToken ?? Infinity) + + (a.completionPricePerToken ?? Infinity); + const priceB = + (b.promptPricePerToken ?? Infinity) + + (b.completionPricePerToken ?? Infinity); + return priceA - priceB; + }), + [modelConfigsQuery.data], + ); const [lmStudioBaseUrl, setLmStudioBaseUrl] = useState( "http://localhost:1234/v1", ); @@ -1220,6 +1276,14 @@ export default function Settings({ tab = "general" }: SettingsProps) { // Invalidate the API keys query so components using useApiKeys will refresh void queryClient.invalidateQueries({ queryKey: ["apiKeys"] }); + + // When the OpenRouter key changes, model configs need to be re-fetched + // (OpenRouter models are only downloaded when the key is present) + if (provider === "openrouter") { + void queryClient.invalidateQueries({ + queryKey: ["modelConfigs"], + }); + } }; useEffect(() => { @@ -1228,8 +1292,6 @@ export default function Settings({ tab = "general" }: SettingsProps) { setSansFont(settings.sansFont ?? "Geist"); setMonoFont(settings.monoFont ?? "Fira Code"); setApiKeys(settings.apiKeys ?? {}); - setQuickChatEnabled(settings.quickChat?.enabled ?? true); - setQuickChatShortcut(settings.quickChat?.shortcut ?? "Alt+Space"); setAutoConvertLongText(settings.autoConvertLongText ?? true); setAutoScrapeUrls(settings.autoScrapeUrls ?? true); setCautiousEnter(settings.cautiousEnter ?? false); @@ -1237,32 +1299,22 @@ export default function Settings({ tab = "general" }: SettingsProps) { setLmStudioBaseUrl( settings.lmStudioBaseUrl ?? "http://localhost:1234/v1", ); + setTitleGenerationModelConfigId( + settings.titleGenerationModelConfigId, + ); }; void loadSettings(); }, [db, setMonoFont, setSansFont, settingsManager]); - const handleQuickChatShortcutChange = async (value: string) => { - setQuickChatShortcut(value); - const currentSettings = await settingsManager.get(); - void settingsManager.set({ - ...currentSettings, - quickChat: { - ...currentSettings.quickChat, - shortcut: value, - }, - }); - }; - - const handleQuickChatEnabledChange = async (enabled: boolean) => { - setQuickChatEnabled(enabled); + const handleTitleGenerationModelChange = async ( + value: string | undefined, + ) => { + setTitleGenerationModelConfigId(value); const currentSettings = await settingsManager.get(); void settingsManager.set({ ...currentSettings, - quickChat: { - ...currentSettings.quickChat, - enabled, - }, + titleGenerationModelConfigId: value, }); }; @@ -1313,20 +1365,6 @@ export default function Settings({ tab = "general" }: SettingsProps) { }); }; - const onDefaultQcShortcutClick = async () => { - setQuickChatShortcut("Alt+Space"); - setQuickChatEnabled(true); - const currentSettings = await settingsManager.get(); - void settingsManager.set({ - ...currentSettings, - quickChat: { - ...currentSettings.quickChat, - shortcut: "Alt+Space", - enabled: true, - }, - }); - }; - const onLmStudioBaseUrlChange = async ( e: React.ChangeEvent<HTMLInputElement>, ) => { @@ -1385,8 +1423,9 @@ export default function Settings({ tab = "general" }: SettingsProps) { {/* Settings Sidebar */} <div className="w-52 bg-sidebar p-4 overflow-y-auto border-r"> <div className="flex flex-col gap-1"> - {Object.entries(TABS).map( - ([id, { label, icon: Icon }]) => ( + {SETTINGS_TAB_ORDER.map((id) => { + const { label, icon: Icon } = TABS[id]; + return ( <button key={id} onClick={() => { @@ -1395,7 +1434,7 @@ export default function Settings({ tab = "general" }: SettingsProps) { "https://docs.chorus.sh", ); } else { - setActiveTab(id as SettingsTabId); + setActiveTab(id); } }} className={cn( @@ -1415,8 +1454,8 @@ export default function Settings({ tab = "general" }: SettingsProps) { )} </span> </button> - ), - )} + ); + })} </div> </div> @@ -1555,6 +1594,54 @@ export default function Settings({ tab = "general" }: SettingsProps) { </Select> </div> + <div> + <label + htmlFor="title-model-selector" + className="block font-semibold mb-1" + > + Chat title model + </label> + <p className="text-sm text-muted-foreground mb-2"> + Model used to auto-generate chat titles. + Defaults to the ambient chat model. + </p> + <Select + value={ + titleGenerationModelConfigId ?? + "__ambient__" + } + onValueChange={(value) => + void handleTitleGenerationModelChange( + value === "__ambient__" + ? undefined + : value, + ) + } + > + <SelectTrigger + id="title-model-selector" + className="w-full" + > + <SelectValue placeholder="Ambient model" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="__ambient__"> + Ambient model (default) + </SelectItem> + {cheapOpenRouterModelOptions.map( + (config) => ( + <SelectItem + key={config.id} + value={config.id} + > + {config.displayName} + </SelectItem> + ), + )} + </SelectContent> + </Select> + </div> + <div className="flex items-center justify-between pt-6"> <div className="space-y-0.5"> <div className="font-semibold "> @@ -1776,96 +1863,18 @@ export default function Settings({ tab = "general" }: SettingsProps) { </div> )} - {activeTab === "quick-chat" && ( - <div className="space-y-6 max-w-2xl"> - <div> - <h2 className="text-2xl font-semibold mb-2"> - Ambient Chat - </h2> - </div> - <div className="space-y-4"> - <div className="flex items-center justify-between"> - <div className="space-y-0.5"> - <label className="font-semibold"> - Ambient Chat - </label> - <p className="text-sm text-muted-foreground"> - Start an ambient chat with{" "} - <span className="font-mono"> - {typeof quickChatShortcut === - "string" - ? quickChatShortcut - : "Alt+Space"} - </span> - </p> - </div> - <Switch - checked={quickChatEnabled} - onCheckedChange={(enabled) => - void handleQuickChatEnabledChange( - enabled, - ) - } - /> - </div> + {activeTab === "visible-models" && <VisibleModelsTab />} - <div className="space-y-2"> - <label className="font-semibold"> - Keyboard Shortcut - </label> - <p className="text-sm text-muted-foreground"> - Enter the shortcut you want to use to - start an ambient chat. - </p> - <ShortcutRecorder - value={quickChatShortcut} - onChange={(shortcut) => - void handleQuickChatShortcutChange( - shortcut, - ) - } - /> - <div className="flex justify-end gap-2"> - <Button - variant="outline" - size="sm" - onClick={() => - void onDefaultQcShortcutClick() - } - > - Set to default - </Button> - <Button - variant="default" - size="sm" - onClick={() => { - if (!quickChatShortcut.trim()) { - toast.error( - "Invalid shortcut", - { - description: - "Shortcut cannot be empty", - }, - ); - return; - } - void relaunch().catch( - console.error, - ); - }} - > - Save and restart - </Button> - </div> - </div> + {activeTab === "model-profiles" && <ModelProfilesTab />} - <Separator /> + {activeTab === "prompt-profiles" && <PromptProfilesTab />} - <div className="space-y-4"> - <AccessibilitySettings /> - </div> - </div> - </div> + {activeTab === "defaults" && ( + <DefaultsTab + onOpenVisibleModels={() => + setActiveTab("visible-models") + } + /> )} {activeTab === "connections" && ( diff --git a/src/ui/components/SortableColumnItem.tsx b/src/ui/components/SortableColumnItem.tsx new file mode 100644 index 00000000..8356f2f1 --- /dev/null +++ b/src/ui/components/SortableColumnItem.tsx @@ -0,0 +1,120 @@ +import React from "react"; +import { useDraggable, useDroppable } from "@dnd-kit/core"; + +/** + * Combines useDraggable + useDroppable on the same element so it acts as + * both a drag source and a drop target — the standard @dnd-kit pattern for + * building a sortable list without @dnd-kit/sortable. + * + * While dragging, the original element is hidden (opacity 0) so only the + * DragOverlay provided by the parent DndContext is visible. + * + * When another item is being dragged over a neighbor, items between the + * drag source and the drop target shift horizontally to preview the + * reorder. + */ +export function SortableColumnItem({ + id, + disabled, + className, + activeDragId, + overId, + itemOrder, + children, +}: { + id: string; + disabled: boolean; + className: string; + /** The id of the item currently being dragged (null if no drag) */ + activeDragId: string | null; + /** The id of the item currently being hovered over (null if none) */ + overId: string | null; + /** The current order of item ids, used to calculate shift direction */ + itemOrder: string[]; + children: ( + listeners: ReturnType<typeof useDraggable>["listeners"], + ) => React.ReactNode; +}) { + const { + attributes, + listeners, + setNodeRef: setDragRef, + isDragging, + } = useDraggable({ id, disabled }); + const { setNodeRef: setDropRef } = useDroppable({ id }); + + // Calculate whether this item should shift to make room for the dragged item + let translatePercent = 0; + let replacementDirection: "left" | "right" | null = null; + if (activeDragId && overId && activeDragId !== overId && !isDragging) { + const activeIndex = itemOrder.indexOf(activeDragId); + const overIndex = itemOrder.indexOf(overId); + const myIndex = itemOrder.indexOf(id); + + if (activeIndex !== -1 && overIndex !== -1 && myIndex !== -1) { + // Dragging right: items between active+1..over shift left + if ( + activeIndex < overIndex && + myIndex > activeIndex && + myIndex <= overIndex + ) { + translatePercent = -100; + } + // Dragging left: items between over..active-1 shift right + if ( + activeIndex > overIndex && + myIndex >= overIndex && + myIndex < activeIndex + ) { + translatePercent = 100; + } + + // Highlight the hovered replacement target so it's clear which item + // is being displaced by the drop. + if (id === overId) { + replacementDirection = + activeIndex < overIndex ? "left" : "right"; + } + } + } + + const replacementNudgePx = + replacementDirection === "left" + ? -14 + : replacementDirection === "right" + ? 14 + : 0; + const transform = + translatePercent !== 0 || replacementNudgePx !== 0 + ? `translateX(calc(${translatePercent}% + ${replacementNudgePx}px))` + : undefined; + const isReplacementTarget = replacementDirection !== null; + const stackOffset = replacementNudgePx > 0 ? 8 : -8; + + return ( + <div + ref={(node) => { + setDragRef(node); + setDropRef(node); + }} + {...attributes} + className={className} + style={{ + opacity: isDragging ? 0 : isReplacementTarget ? 0.92 : 1, + transform, + boxShadow: isReplacementTarget + ? `${stackOffset}px 0 0 hsl(var(--border-accent) / 0.22)` + : undefined, + outline: isReplacementTarget + ? "1px dashed hsl(var(--border-accent) / 0.7)" + : undefined, + outlineOffset: isReplacementTarget ? "2px" : undefined, + transition: activeDragId + ? "transform 220ms ease, box-shadow 180ms ease, opacity 180ms ease" + : undefined, + }} + > + {children(disabled ? undefined : listeners)} + </div> + ); +} diff --git a/src/ui/components/VisibleModelsTab.tsx b/src/ui/components/VisibleModelsTab.tsx new file mode 100644 index 00000000..dd93b55b --- /dev/null +++ b/src/ui/components/VisibleModelsTab.tsx @@ -0,0 +1,288 @@ +import { useState } from "react"; +import { Button } from "./ui/button"; +import { Switch } from "./ui/switch"; +import { + useProviderVisibleModels, + useSetModelVisibility, + useSetAllProviderModelsVisible, +} from "@core/chorus/api/ProviderVisibilityAPI"; +import { + useModelConfigs, + useRefreshOpenRouterModels, + useRefreshOllamaModels, + useRefreshLMStudioModels, +} from "@core/chorus/api/ModelsAPI"; +import { ModelConfig } from "@core/chorus/Models"; +import { Loader2, RefreshCcw } from "lucide-react"; +import { getProviderName } from "@core/chorus/Models"; + +const FETCHABLE_PROVIDERS = ["openrouter", "ollama", "lmstudio"] as const; +type FetchableProvider = (typeof FETCHABLE_PROVIDERS)[number]; + +const PROVIDER_LABELS: Record<string, string> = { + openrouter: "OpenRouter", + ollama: "Ollama", + lmstudio: "LM Studio", + anthropic: "Anthropic", + openai: "OpenAI", + google: "Google", + grok: "Grok", + perplexity: "Perplexity", +}; + +/** + * Extracts the sub-provider org from a model ID. + * For "openrouter::meta-llama/llama-4-scout" returns "meta-llama". + * For models without an org prefix returns null. + */ +function getSubProvider(modelId: string): string | null { + const modelPart = modelId.split("::")[1]; + if (!modelPart) return null; + const slashIdx = modelPart.indexOf("/"); + if (slashIdx === -1) return null; + return modelPart.slice(0, slashIdx); +} + +export function VisibleModelsTab() { + const { data: visibleModels, isLoading } = useProviderVisibleModels(); + const { data: allModels } = useModelConfigs(); + const setVisibility = useSetModelVisibility(); + const setAllVisibility = useSetAllProviderModelsVisible(); + + const refreshOpenRouter = useRefreshOpenRouterModels(); + const refreshOllama = useRefreshOllamaModels(); + const refreshLMStudio = useRefreshLMStudioModels(); + const [fetchingProviders, setFetchingProviders] = useState< + Record<FetchableProvider, boolean> + >({ openrouter: false, ollama: false, lmstudio: false }); + + // Selected sub-provider filter per top-level provider (null = show all) + const [subProviderFilter, setSubProviderFilter] = useState< + Record<string, string | null> + >({}); + + if (isLoading || !allModels) { + return ( + <div className="flex items-center justify-center h-full"> + <Loader2 className="w-8 h-8 animate-spin text-muted-foreground" /> + </div> + ); + } + + const handleFetchModels = async (provider: FetchableProvider) => { + setFetchingProviders((prev) => ({ ...prev, [provider]: true })); + try { + if (provider === "openrouter") + await refreshOpenRouter.mutateAsync(); + else if (provider === "ollama") await refreshOllama.mutateAsync(); + else if (provider === "lmstudio") + await refreshLMStudio.mutateAsync(); + } finally { + setFetchingProviders((prev) => ({ ...prev, [provider]: false })); + } + }; + + // Group models by provider + const allProviders = Array.from( + new Set(allModels.map((m) => getProviderName(m.modelId))), + ); + + const fetchableWithModels = FETCHABLE_PROVIDERS.filter((p) => + allProviders.includes(p), + ); + const fetchableWithoutModels = FETCHABLE_PROVIDERS.filter( + (p) => !allProviders.includes(p), + ); + const otherProviders = allProviders.filter( + (p) => !FETCHABLE_PROVIDERS.includes(p as FetchableProvider), + ); + + const orderedProviders = [ + ...otherProviders, + ...fetchableWithModels, + ...fetchableWithoutModels, + ]; + + return ( + <div className="space-y-8 max-w-2xl"> + <div> + <h2 className="text-2xl font-semibold mb-2">Visible Models</h2> + <p className="text-sm text-muted-foreground"> + Fetch and choose which models appear in the chat model + picker and in your model profiles. + </p> + </div> + + {orderedProviders.map((provider) => { + const providerModels = allModels.filter( + (m) => getProviderName(m.modelId) === provider, + ); + const isFetchable = FETCHABLE_PROVIDERS.includes( + provider as FetchableProvider, + ); + const isFetching = + isFetchable && + fetchingProviders[provider as FetchableProvider]; + + // Compute unique sub-providers for this top-level provider + const subProviders = Array.from( + new Set( + providerModels + .map((m) => getSubProvider(m.modelId)) + .filter((s): s is string => s !== null), + ), + ).sort(); + + const activeSubFilter = subProviderFilter[provider] ?? null; + + // Apply sub-provider filter + const visibleProviderModels: ModelConfig[] = + activeSubFilter !== null + ? providerModels.filter( + (m) => + getSubProvider(m.modelId) === activeSubFilter, + ) + : providerModels; + + const isAllVisible = visibleProviderModels.every((m) => { + const v = visibleModels?.find( + (vm) => vm.modelId === m.modelId, + ); + return v ? v.isVisible : true; + }); + + return ( + <div + key={provider} + className="space-y-4 border rounded-lg p-4" + > + <div className="flex items-center justify-between"> + <h3 className="font-semibold"> + {PROVIDER_LABELS[provider] ?? provider} + </h3> + <div className="flex items-center gap-2"> + {isFetchable && ( + <Button + variant="outline" + size="sm" + disabled={isFetching} + onClick={() => + void handleFetchModels( + provider as FetchableProvider, + ) + } + > + <RefreshCcw + className={`w-3 h-3 mr-1 ${isFetching ? "animate-spin" : ""}`} + /> + {isFetching + ? "Fetching..." + : "Fetch Models"} + </Button> + )} + {visibleProviderModels.length > 0 && ( + <Button + variant="outline" + size="sm" + onClick={() => + setAllVisibility.mutate({ + providerName: provider, + modelIds: + visibleProviderModels.map( + (m) => m.modelId, + ), + isVisible: !isAllVisible, + }) + } + > + {isAllVisible ? "Hide All" : "Show All"} + </Button> + )} + </div> + </div> + + {/* Sub-provider filter chips */} + {subProviders.length > 1 && ( + <div className="flex flex-wrap gap-1.5"> + <button + onClick={() => + setSubProviderFilter((prev) => ({ + ...prev, + [provider]: null, + })) + } + className={`px-2.5 py-0.5 rounded-full text-xs border transition-colors ${ + activeSubFilter === null + ? "bg-primary text-primary-foreground border-primary" + : "bg-background text-muted-foreground border-border hover:border-foreground/40" + }`} + > + All + </button> + {subProviders.map((sub) => ( + <button + key={sub} + onClick={() => + setSubProviderFilter((prev) => ({ + ...prev, + [provider]: + prev[provider] === sub + ? null + : sub, + })) + } + className={`px-2.5 py-0.5 rounded-full text-xs border transition-colors ${ + activeSubFilter === sub + ? "bg-primary text-primary-foreground border-primary" + : "bg-background text-muted-foreground border-border hover:border-foreground/40" + }`} + > + {sub} + </button> + ))} + </div> + )} + + {providerModels.length === 0 ? ( + <p className="text-sm text-muted-foreground"> + {isFetchable + ? 'No models loaded yet. Click "Fetch Models" to load the model list.' + : "No models available."} + </p> + ) : ( + <div className="space-y-2"> + {visibleProviderModels.map((m) => { + const visibility = visibleModels?.find( + (vm) => vm.modelId === m.modelId, + ); + const isVisible = visibility + ? visibility.isVisible + : true; + + return ( + <div + key={m.id} + className="flex items-center justify-between text-sm" + > + <span>{m.displayName}</span> + <Switch + checked={isVisible} + onCheckedChange={(checked) => + setVisibility.mutate({ + providerName: provider, + modelId: m.modelId, + isVisible: checked, + }) + } + /> + </div> + ); + })} + </div> + )} + </div> + ); + })} + </div> + ); +} diff --git a/src/ui/components/ui/command.tsx b/src/ui/components/ui/command.tsx index fff2decc..401f214d 100644 --- a/src/ui/components/ui/command.tsx +++ b/src/ui/components/ui/command.tsx @@ -58,23 +58,39 @@ const CommandDialog = ({ ); }; +type CommandInputProps = React.ComponentPropsWithoutRef< + typeof CommandPrimitive.Input +> & { + /** Shown at the end of the search row (e.g. keyboard hints). */ + trailing?: React.ReactNode; +}; + const CommandInput = React.forwardRef< React.ElementRef<typeof CommandPrimitive.Input>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> ->(({ className, ...props }, ref) => ( + CommandInputProps +>(({ className, trailing, ...props }, ref) => ( <div - className="flex items-center border-b px-3 bg-background" + className="relative flex items-center border-b px-3 bg-background min-w-0" cmdk-input-wrapper="" > <Search className="mr-2 !h-4 !w-4 shrink-0 opacity-50" /> <CommandPrimitive.Input ref={ref} className={cn( - "flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground placeholder:text-base disabled:cursor-not-allowed disabled:opacity-50 bg-background", + "flex h-11 min-w-0 flex-1 rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground placeholder:text-base disabled:cursor-not-allowed disabled:opacity-50 bg-background", + trailing && "pr-[10.5rem]", className, )} {...props} /> + {trailing ? ( + <div + className="pointer-events-none absolute inset-y-0 right-3 z-[1] flex items-center justify-end gap-1.5 text-xs text-muted-foreground whitespace-nowrap" + aria-hidden + > + {trailing} + </div> + ) : null} </div> )); diff --git a/src/ui/hooks/useShortcut.ts b/src/ui/hooks/useShortcut.ts index 482e20c6..a2d5791b 100644 --- a/src/ui/hooks/useShortcut.ts +++ b/src/ui/hooks/useShortcut.ts @@ -25,6 +25,9 @@ export function useShortcut( // "global" here means the scope the shortcut is declared in // if its parent component is torn down, the shortcut will be removed isGlobal?: boolean; + // defaults to true + // set to false to disable the shortcut without unmounting the hook + enabled?: boolean; }, ) { const keys = combo.map((key) => key.toLowerCase()); @@ -32,6 +35,8 @@ export function useShortcut( const activeDialogId = useDialogStore((state) => state.activeDialogId); const handler: ShortcutHandler = useCallback( (event) => { + const enabled = options?.enabled ?? true; + if (!enabled) return; const enableOnChatFocus = options?.enableOnChatFocus ?? true; const enableOnDialogIds = options?.enableOnDialogIds ?? []; const isGlobal = options?.isGlobal ?? false; diff --git a/src/ui/lib/models.ts b/src/ui/lib/models.ts index a29ba60d..a77367ab 100644 --- a/src/ui/lib/models.ts +++ b/src/ui/lib/models.ts @@ -33,12 +33,3 @@ export const MODEL_IDS = { export const OPENROUTER_CUSTOM_PROVIDER_LOGOS: Record<string, ProviderName> = { "openrouter::x-ai/grok-4": "grok", }; - -// Flatten the MODEL_IDS object into a single array of allowed IDs -export const ALLOWED_MODEL_IDS_FOR_QUICK_CHAT: string[] = [ - ...Object.values(MODEL_IDS).flatMap((tier) => Object.values(tier)), - // Add our custom models for quick chat - "24711c64-725c-4bdd-b5eb-65fe1dbfcde8", // Ambient Claude - "google::ambient-gemini-2.5-pro-preview-03-25", // Ambient Gemini - "openrouter::qwen/qwen3-32b", // Qwen 32B -];