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/.prettierignore b/.prettierignore index c07fc69f..9cf3f481 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ src-tauri/gen pnpm-lock.yaml -.github \ No newline at end of file +.github +.context 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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..19a18b46 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,197 @@ +# Changelog + +## v0.14.15 + +Released 2026-04-09 + +### Features + +- Per-tool and per-project YOLO mode for granular auto-accept permissions +- OpenRouter actual model attribution and cost tracking in streaming responses +- Multiple sub-provider filter selection in model search +- Collapsible chat input for long text + +### Improvements + +- Improved fuzzy search with scored substring matching and provider filtering +- Better autorouter/freerouter labels and free-tier display names +- Selected provider chips stay visible during search + +### Fixes + +- Fix YOLO deny precedence and memoize permission tool list +- Fix MCP terminal startup +- Fix model_config UUID passed when replying to single model +- Migration 145 legacy compatibility for dev instances + +## v0.14.14 + +Released 2026-04-07 + +- **Fix profile model selection fallback** - profile model selection now falls back correctly instead of leaving compare state in a bad state. +- **Fix cmd+number keyboard navigation** - selected blocks and columns now scroll into view when using cmd+number. +- **Fix cmd+number toggle-off behavior** - reselecting the same block with cmd+number now deselects it as expected. + +### 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. Alternatively, run: +> +> ``` +> xattr -d com.apple.quarantine /Applications/Chorus.app +> ``` + +## 0.14.6-PRE (pre-release) + +Released 2026-04-08 + +This is a testing build which should have the same feature-set as v0.14.14. Intended use is for proof-of-concept for merging with base fork meltylabs/chorus + +### 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. Alternatively, run: +> +> ``` +> xattr -d com.apple.quarantine /Applications/Chorus.app +> ``` + +## v0.14.13 + +Released 2026-03-31 + +- **Deselect model block when clicking outside or re-clicking** — exit the model block reorder mode by clicking anywhere outside or re-clicking the selected block +- **Fix sidepane blur on reply messages** — reply messages in the sidepane no longer have the blur effect incorrectly applied +- **Fix cmd+number keybindings** — cmd+1-8 now works for both tools and compare blocks; cmd+1 always selects the leftmost column; cmd/ctrl+shift+space scrolls the selected column into view + +### 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. Alternatively, run: +> +> ``` +> xattr -d com.apple.quarantine /Applications/Chorus.app +> ``` + +## v0.14.12 + +Released 2026-03-31 + +### New Features + +- **Defaults settings tab** — New Settings → Defaults section consolidates new-chat preferences: default prompt profile, default chat models (multi-model), fallback model (with optional model profile constraint), and default ambient chat model. Replaces the former separate Ambient Chat tab controls. + +- **Per-chat model selection persistence** — Selected models are now saved per chat. Switching chats and returning preserves your model selection. New chats are initialized from your Default Chat Models setting, falling back to the ambient compare list. + +- **Per-project default prompt profile** — Each project can now have its own default prompt profile (set in the project view), which overrides the global default when creating new chats in that project. + +- **Fresh-install model preferences** — On first launch, Gemini 2.5 Flash Lite is seeded as the default ambient, fallback, and chat model. + +### 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. + +## v0.14.11 + +Released 2026-03-31 + +- Initial implementation of reorder chat functionality + +### 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. + +## v0.14.10 + +Released 2026-03-30 + +### 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. + +## v0.14.9 + +Released 2026-03-30 + +### 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. + +## v0.14.8 + +Released 2026-03-30 + +- **Multi-Model Column Minimization**: Added a minimize button to each model column in the multi-model view to help manage your workspace. + +- **Automated Minimization**: Models that fail to return a response after stopping will now automatically minimize to reduce clutter. + +- **Sidebar Integration**: Minimized models are easily accessible in the sidebar above Projects, where they can be clicked to quickly restore the column. + +- **Smart Message Sending**: Minimized models are automatically excluded from new message sends until you expand them again. + +### 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. +> Alternatively, remove the quarantine flag via Terminal: `xattr -d com.apple.quarantine Chorus.app` + +## v0.14.7 + +Released 2026-03-29 + +- Add support for ⌘ Command + ⇧ Shift + A to select all models in a profile + +### 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. + +## v0.14.6 + +Released 2026-03-29 + +- Update Ambient chat model to use Gemini 2.5 flash + +### 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. + +## v0.14.5 + +Released 2026-03-29 + +- Added profiles for favorite models in the chat window +- Dynamically fetch and select models from providers +- Prompt profiles + +### 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. diff --git a/README.md b/README.md index e682b6ea..3fac8c3d 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,21 @@ 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 + +See [CHANGELOG.md](CHANGELOG.md) for the full release history and release-by-release changes for this fork. + +> **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 +44,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..9ea6edf7 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "chorus", "license": "MIT", "private": true, - "version": "0.14.5", + "version": "0.14.15", "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/lib.rs b/src-tauri/src/lib.rs index 5bd89bc8..7766c5b6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,8 @@ use tauri::menu::{MenuBuilder, MenuItem, PredefinedMenuItem, SubmenuBuilder}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::{Emitter, Listener, Manager}; +use rusqlite::{Connection, OpenFlags}; +use std::path::{Path, PathBuf}; #[cfg(target_os = "macos")] use tauri_nspanel::ManagerExt; use tauri_plugin_global_shortcut::{Code, Modifiers, Shortcut, ShortcutState}; @@ -14,6 +16,9 @@ pub mod migrations; mod window; const DB_URL: &str = "sqlite:chats.db"; +const LEGACY_MIGRATION_145_ENV_VAR: &str = "CHORUS_USE_LEGACY_MIGRATION_145"; +const LEGACY_MIGRATION_145_DESCRIPTION: &str = + "add tool_yolo table and projects.yolo_mode column"; pub const SPOTLIGHT_LABEL: &str = "quick-chat"; @@ -118,11 +123,62 @@ fn parse_shortcut(shortcut_str: &str) -> Option { Some(Shortcut::new(Some(modifiers), code)) } +#[cfg(target_os = "macos")] +fn get_db_path_for_identifier(identifier: &str) -> Option { + let home = std::env::var("HOME").ok()?; + Some( + PathBuf::from(home) + .join("Library") + .join("Application Support") + .join(identifier) + .join("chats.db"), + ) +} + +#[cfg(not(target_os = "macos"))] +fn get_db_path_for_identifier(_identifier: &str) -> Option { + None +} + +fn db_has_legacy_migration_145(db_path: &Path) -> bool { + let Ok(connection) = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY) + else { + return false; + }; + + let description = connection.query_row( + "SELECT description FROM _sqlx_migrations WHERE version = 145", + [], + |row| row.get::<_, String>(0), + ); + + matches!( + description.as_deref(), + Ok(LEGACY_MIGRATION_145_DESCRIPTION) + ) +} + +fn set_migration_145_compatibility_mode(identifier: &str) { + let db_path = get_db_path_for_identifier(identifier); + let use_legacy_145 = db_path + .as_ref() + .is_some_and(|path| db_has_legacy_migration_145(path)); + + if use_legacy_145 { + std::env::set_var(LEGACY_MIGRATION_145_ENV_VAR, "1"); + } else { + std::env::remove_var(LEGACY_MIGRATION_145_ENV_VAR); + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { #[cfg(debug_assertions)] // only enable instrumentation in development builds let devtools = tauri_plugin_devtools::init(); + let context = tauri::generate_context!(); + set_migration_145_compatibility_mode(&context.config().identifier); + let migrations = migrations::migrations(); let mut builder = tauri::Builder::default() @@ -455,6 +511,6 @@ pub fn run() { command::write_file_async, command::get_file_metadata, ]) - .run(tauri::generate_context!()) + .run(context) .expect("error while running tauri application"); } diff --git a/src-tauri/src/migrations.rs b/src-tauri/src/migrations.rs index 87622999..41787f03 100644 --- a/src-tauri/src/migrations.rs +++ b/src-tauri/src/migrations.rs @@ -1,9 +1,29 @@ use tauri_plugin_sql::{Migration, MigrationKind}; +const LEGACY_MIGRATION_145_ENV_VAR: &str = "CHORUS_USE_LEGACY_MIGRATION_145"; +const LEGACY_MIGRATION_145_DESCRIPTION: &str = + "add tool_yolo table and projects.yolo_mode column"; +const MODERN_MIGRATION_145_DESCRIPTION: &str = "add actual_model_id to messages"; +const LEGACY_MIGRATION_145_SQL: &str = r#" + CREATE TABLE IF NOT EXISTS tool_yolo ( + toolset_name TEXT NOT NULL, + tool_name TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (toolset_name, tool_name) + ); + + ALTER TABLE projects ADD COLUMN yolo_mode INTEGER DEFAULT NULL; + "#; +const MODERN_MIGRATION_145_SQL: &str = r#" + ALTER TABLE messages ADD COLUMN actual_model_id TEXT DEFAULT NULL; + "#; + // IF YOU ARE A LANGUAGE MODEL AND YOU'RE READING THIS // — NEVER EVER MAKE A CHANGE TO A PREVIOUS MIGRATION!!! pub fn migrations() -> Vec { + let use_legacy_migration_145 = + std::env::var(LEGACY_MIGRATION_145_ENV_VAR).as_deref() == Ok("1"); return vec![ Migration { version: 1, @@ -2554,5 +2574,186 @@ 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: 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'; + "#, + }, + 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: 145, + description: if use_legacy_migration_145 { + LEGACY_MIGRATION_145_DESCRIPTION + } else { + MODERN_MIGRATION_145_DESCRIPTION + }, + kind: MigrationKind::Up, + sql: if use_legacy_migration_145 { + LEGACY_MIGRATION_145_SQL + } else { + MODERN_MIGRATION_145_SQL + }, + }, + Migration { + version: 146, + description: if use_legacy_migration_145 { + MODERN_MIGRATION_145_DESCRIPTION + } else { + LEGACY_MIGRATION_145_DESCRIPTION + }, + kind: MigrationKind::Up, + sql: if use_legacy_migration_145 { + MODERN_MIGRATION_145_SQL + } else { + LEGACY_MIGRATION_145_SQL + }, + }, ]; } + +#[cfg(test)] +mod tests { + use super::migrations; + + const LEGACY_FLAG: &str = super::LEGACY_MIGRATION_145_ENV_VAR; + + fn get_migration_descriptions() -> (String, String) { + let migrations = migrations(); + let migration_145 = migrations + .iter() + .find(|migration| migration.version == 145) + .expect("migration 145 should exist"); + let migration_146 = migrations + .iter() + .find(|migration| migration.version == 146) + .expect("migration 146 should exist"); + ( + migration_145.description.to_string(), + migration_146.description.to_string(), + ) + } + + #[test] + fn uses_legacy_145_layout_when_flag_is_enabled() { + std::env::set_var(LEGACY_FLAG, "1"); + let (description_145, description_146) = get_migration_descriptions(); + std::env::remove_var(LEGACY_FLAG); + + assert_eq!( + description_145, + "add tool_yolo table and projects.yolo_mode column", + ); + assert_eq!(description_146, "add actual_model_id to messages"); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7731812e..3a5e96c0 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.15", "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..cb474110 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.15", "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..36356189 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.15", "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/ChatState.ts b/src/core/chorus/ChatState.ts index 4fba7042..2be73f54 100644 --- a/src/core/chorus/ChatState.ts +++ b/src/core/chorus/ChatState.ts @@ -33,6 +33,7 @@ export interface Message { blockType: BlockType; text: string; model: string; + actualModelId?: string; selected: boolean; attachments: Attachment[] | undefined; isReview: boolean; 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/ProviderOpenRouter.ts b/src/core/chorus/ModelProviders/ProviderOpenRouter.ts index 31c4a8a5..cd78eb7b 100644 --- a/src/core/chorus/ModelProviders/ProviderOpenRouter.ts +++ b/src/core/chorus/ModelProviders/ProviderOpenRouter.ts @@ -105,16 +105,19 @@ export class ProviderOpenRouter implements IProvider { const chunks: OpenAI.ChatCompletionChunk[] = []; let generationId: string | undefined; + let responseModel: string | undefined; try { const stream = await client.chat.completions.create(params); for await (const chunk of stream) { chunks.push(chunk); - // Capture the generation ID from the first chunk if (!generationId && chunk.id) { generationId = chunk.id; } + if (!responseModel && chunk.model) { + responseModel = chunk.model; + } if (chunk.choices[0]?.delta?.content) { onChunk(chunk.choices[0].delta.content); } @@ -163,12 +166,17 @@ export class ProviderOpenRouter implements IProvider { // Extract usage data from the last chunk const lastChunk = chunks[chunks.length - 1]; + const extendedUsage = lastChunk?.usage as + | (OpenAI.CompletionUsage & { cost?: number }) + | undefined; let usageData: | { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number; generation_id?: string; + model?: string; + cost?: number; } | undefined; @@ -178,11 +186,13 @@ export class ProviderOpenRouter implements IProvider { completion_tokens: lastChunk.usage.completion_tokens, total_tokens: lastChunk.usage.total_tokens, generation_id: generationId, + model: responseModel, + cost: extendedUsage?.cost, }; - } else if (generationId) { - // Even if no usage data in chunks, pass the generation ID + } else if (generationId || responseModel) { usageData = { generation_id: generationId, + model: responseModel, }; } 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..8ee1208e 100644 --- a/src/core/chorus/Models.ts +++ b/src/core/chorus/Models.ts @@ -206,11 +206,52 @@ 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; total_tokens?: number; generation_id?: string; // OpenRouter generation ID for fetching actual costs + model?: string; // Actual model used (e.g. from OpenRouter auto-routing) + cost?: number; // Cost in USD (from provider response) }; export type StreamResponseParams = { diff --git a/src/core/chorus/Toolsets.ts b/src/core/chorus/Toolsets.ts index f04db223..4e1a14f9 100644 --- a/src/core/chorus/Toolsets.ts +++ b/src/core/chorus/Toolsets.ts @@ -170,6 +170,32 @@ function configsEqual( ); } +const MCP_CONNECT_TIMEOUT_MS = 15_000; +const MCP_LIST_TOOLS_TIMEOUT_MS = 15_000; + +async function withTimeout( + promise: Promise, + timeoutMs: number, + operationName: string, +): Promise { + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject( + new Error(`${operationName} timed out after ${timeoutMs}ms`), + ); + }, timeoutMs); + }); + + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } +} + type MCPContentBlock = | { type: "text"; text: string } | { type: "image"; image: string } @@ -267,6 +293,7 @@ export abstract class MCPServer { private _status: ToolsetStatus = { status: "stopped" }; private _logs: string = ""; // accumulated logs private activeConfig?: Record = undefined; + private _startPromise: Promise | null = null; constructor() { this.mcp = new Client({ name: "mcp-client-cli", version: "1.0.0" }); @@ -299,49 +326,70 @@ export abstract class MCPServer { await this.ensureStop(); } - if (this._status.status !== "stopped") { - // technically, we'd want to wait until it's running, but - // this is good enough for now + if (this._status.status === "running") { return true; } + if (this._startPromise) { + return this._startPromise; + } - console.info("Starting MCP server", config); - this._status = { status: "starting" }; - this._logs = ""; // clear any previous logs + this._startPromise = (async () => { + console.info("Starting MCP server", config); + this._status = { status: "starting" }; + this._logs = ""; // clear any previous logs - try { - console.log("starting mcp server"); - const serverParams = this.getExecutionParameters(config); + try { + console.log("starting mcp server"); + const serverParams = this.getExecutionParameters(config); - this.mcp.onerror = (error: Error) => { - console.log("[Toolset] MCP server error", error); - this._logs += error.message + "\n"; - }; + this.mcp.onerror = (error: Error) => { + console.log("[Toolset] MCP server error", error); + this._logs += error.message + "\n"; + }; - this.mcp.onclose = () => { - console.log("[Toolset] MCP server closed"); + this.mcp.onclose = () => { + console.log("[Toolset] MCP server closed"); + this._status = { + status: "stopped", + }; + }; + + const transport = new StdioClientTransportChorus(serverParams); + this.transport = transport; + await withTimeout( + this.mcp.connect(this.transport), + MCP_CONNECT_TIMEOUT_MS, + "MCP server connect", + ); + + if (this._status.status !== "starting") { + await this.transport?.close(); + this.transport = null; + return false; + } + + this.activeConfig = config; + this._status = { + status: "running", + }; + return true; + } catch (e) { + console.error("Error starting MCP server: ", e); + const errorMessage = e instanceof Error ? e.message : String(e); + this._logs += `[Error starting MCP server: ${errorMessage}]\n`; + void this.transport?.close(); this._status = { status: "stopped", }; - }; - - const transport = new StdioClientTransportChorus(serverParams); - this.transport = transport; - await this.mcp.connect(this.transport); + this.transport = null; + this.activeConfig = undefined; + return false; + } + })().finally(() => { + this._startPromise = null; + }); - this.activeConfig = config; - this._status = { - status: "running", - }; - return true; - } catch (e) { - console.error("Error starting MCP server: ", e); - void this.transport?.close(); - this._status = { - status: "stopped", - }; - return false; - } + return this._startPromise; } /** @@ -354,6 +402,7 @@ export abstract class MCPServer { console.info("Stopping MCP server"); this._status = { status: "stopped" }; + this._startPromise = null; try { await this.mcp.close(); @@ -468,6 +517,7 @@ export class Toolset { >(); private servers: MCPServer[] = []; private _status: ToolsetStatus = { status: "stopped" }; + private _startPromise: Promise | null = null; constructor( public readonly name: string, // used to namespace tool names. alphanumeric only, must not contain special characters. @@ -623,70 +673,103 @@ export class Toolset { if (this._status.status === "running") { return true; } - - this._status = { - status: "starting", - }; - - // Start all servers in parallel - const allStarted = _.every( - await Promise.all( - this.servers.map((server) => server.ensureStart(config)), - ), - Boolean, - ); - - if (!allStarted) { - console.error( - `Failed to start all servers for toolset ${this.name}`, - ); - return false; + if (this._startPromise) { + return this._startPromise; } - // Auto-register tools based on registration options - for (const server of this.servers) { - const options = this._serverRegistrationOptions.get(server); + this._startPromise = (async () => { + this._status = { + status: "starting", + }; - // Skip if no registration options or explicitly set to none - if (!options || options.registration.mode === "none") { - continue; - } + try { + // Start all servers in parallel + const allStarted = _.every( + await Promise.all( + this.servers.map((server) => + server.ensureStart(config), + ), + ), + Boolean, + ); - // Get all tools from the server - const serverTools = await server.listTools(); + if (!allStarted) { + console.error( + `Failed to start all servers for toolset ${this.name}`, + ); + this._status = { + status: "stopped", + }; + return false; + } + + // Auto-register tools based on registration options + for (const server of this.servers) { + const options = this._serverRegistrationOptions.get(server); + + // Skip if no registration options or explicitly set to none + if (!options || options.registration.mode === "none") { + continue; + } + + // Get all tools from the server + const serverTools = await withTimeout( + server.listTools(), + MCP_LIST_TOOLS_TIMEOUT_MS, + `MCP listTools for toolset ${this.name}`, + ); + + // Apply registration options + let filteredTools: ServerTool[] = serverTools; + + if (options.registration.mode === "filter") { + // Filter tools using the provided filter function + filteredTools = serverTools.filter( + options.registration.filter, + ); + } else if (options.registration.mode === "select") { + // Only include tools in the include list + const selectedTools = options.registration.include; + filteredTools = serverTools.filter((serverTool) => + selectedTools.includes(serverTool.nameOnServer), + ); + } + + // Import the filtered tools with any rename mappings and description overrides + this.importServerTools(server, filteredTools, { + renameMap: options.renameMap, + descriptionMap: options.descriptionMap, + }); + } + + if (this._status.status !== "starting") { + return false; + } - // Apply registration options - let filteredTools: ServerTool[] = serverTools; + this._status = { + status: "running", + }; - if (options.registration.mode === "filter") { - // Filter tools using the provided filter function - filteredTools = serverTools.filter(options.registration.filter); - } else if (options.registration.mode === "select") { - // Only include tools in the include list - const selectedTools = options.registration.include; - filteredTools = serverTools.filter((serverTool) => - selectedTools.includes(serverTool.nameOnServer), - ); + return true; + } catch (error) { + console.error(`Failed to start toolset ${this.name}:`, error); + this._status = { + status: "stopped", + }; + return false; } + })().finally(() => { + this._startPromise = null; + }); - // Import the filtered tools with any rename mappings and description overrides - this.importServerTools(server, filteredTools, { - renameMap: options.renameMap, - descriptionMap: options.descriptionMap, - }); - } - - this._status = { - status: "running", - }; - - return true; + return this._startPromise; } /** * Stop all servers */ async ensureStop(): Promise { + this._startPromise = null; await Promise.all(this.servers.map((server) => server.ensureStop())); this._status = { status: "stopped", diff --git a/src/core/chorus/ToolsetsManager.test.ts b/src/core/chorus/ToolsetsManager.test.ts new file mode 100644 index 00000000..70928c6d --- /dev/null +++ b/src/core/chorus/ToolsetsManager.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ToolsetsManager } from "./ToolsetsManager"; +import type { Toolset, UserToolCall } from "./Toolsets"; +import { checkToolPermission } from "./api/ToolPermissionsAPI"; +import { checkToolYolo } from "./api/ToolYoloAPI"; +import { fetchProjectYoloMode } from "./api/ProjectAPI"; +import { fetchAppMetadata } from "./api/AppMetadataAPI"; + +vi.mock("./api/ToolPermissionsAPI", () => ({ + checkToolPermission: vi.fn(), +})); + +vi.mock("./api/ToolYoloAPI", () => ({ + checkToolYolo: vi.fn(), +})); + +vi.mock("./api/ProjectAPI", () => ({ + fetchProjectYoloMode: vi.fn(), +})); + +vi.mock("./api/AppMetadataAPI", () => ({ + fetchAppMetadata: vi.fn(), +})); + +vi.mock("@core/infra/ToolPermissionStore", () => ({ + toolPermissionActions: { + addRequest: vi.fn(), + }, +})); + +describe("ToolsetsManager.executeToolCall", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchProjectYoloMode).mockResolvedValue(undefined); + vi.mocked(fetchAppMetadata).mockResolvedValue({}); + vi.mocked(checkToolYolo).mockResolvedValue(false); + }); + + it("does not execute a tool when permission is always_deny, even if project YOLO is enabled", async () => { + const manager = new ToolsetsManager(); + const executeTool = vi.fn().mockResolvedValue("should-not-run"); + + ( + manager as unknown as { _builtInToolsets: Toolset[] } + )._builtInToolsets = [ + { + name: "web", + executeTool, + listTools: () => [], + } as unknown as Toolset, + ]; + + vi.mocked(fetchProjectYoloMode).mockResolvedValue(true); + vi.mocked(checkToolPermission).mockResolvedValue({ + shouldAsk: false, + isAllowed: false, + permission: null, + }); + + const toolCall: UserToolCall = { + id: "call-1", + namespacedToolName: "web_search", + args: {}, + }; + + const result = await manager.executeToolCall( + toolCall, + "model-x", + "project-1", + ); + + expect(checkToolPermission).toHaveBeenCalledWith( + "web", + "search", + "ask", + ); + expect(executeTool).not.toHaveBeenCalled(); + expect(result.content).toContain("denied by saved preference"); + }); +}); diff --git a/src/core/chorus/ToolsetsManager.ts b/src/core/chorus/ToolsetsManager.ts index 0e987992..42957071 100644 --- a/src/core/chorus/ToolsetsManager.ts +++ b/src/core/chorus/ToolsetsManager.ts @@ -8,6 +8,8 @@ import { } from "./Toolsets"; import { CustomToolset } from "./toolsets/custom"; import { checkToolPermission } from "./api/ToolPermissionsAPI"; +import { checkToolYolo } from "./api/ToolYoloAPI"; +import { fetchProjectYoloMode } from "./api/ProjectAPI"; import { fetchAppMetadata } from "./api/AppMetadataAPI"; import { toolPermissionActions, @@ -54,12 +56,40 @@ export class ToolsetsManager { return [...this._builtInToolsets, ...this._customToolsets]; } + /** + * Resolves effective YOLO mode for a given tool call using precedence: + * per-project override → per-tool YOLO → global YOLO + */ + private async resolveYoloMode( + toolsetName: string, + toolName: string, + projectId?: string, + ): Promise { + // 1. Per-project override (if projectId provided and project has explicit override) + if (projectId) { + const projectYolo = await fetchProjectYoloMode(projectId); + if (projectYolo !== undefined) { + return projectYolo; + } + } + + // 2. Global YOLO — check before per-tool to avoid an extra DB query + const appMetadata = await fetchAppMetadata(); + if (appMetadata?.["yolo_mode"] === "true") { + return true; + } + + // 3. Per-tool YOLO + return checkToolYolo(toolsetName, toolName); + } + /** * Executes a tool call using the appropriate MCP server */ async executeToolCall( toolCall: UserToolCall, modelName?: string, + projectId?: string, ): Promise { const { toolsetName, displayNameSuffix } = parseUserToolNamespacedName( toolCall.namespacedToolName, @@ -73,24 +103,6 @@ export class ToolsetsManager { } try { - // Check if YOLO mode is enabled - const appMetadata = await fetchAppMetadata(); - const yoloMode = appMetadata?.["yolo_mode"] === "true"; - - if (yoloMode) { - // YOLO mode - execute without asking - const resultContent = await toolset.executeTool( - displayNameSuffix, - toolCall.args as Record, - ); - - return { - id: toolCall.id, - content: resultContent, - }; - } - - // Normal permission flow const customToolset = this._customToolsets.find( (t) => t.name === toolsetName, ); @@ -104,6 +116,33 @@ export class ToolsetsManager { defaultPermission, ); + if (!permissionCheck.shouldAsk && !permissionCheck.isAllowed) { + // Permission is always_deny and remains a hard block even with YOLO enabled. + return { + id: toolCall.id, + content: `Tool execution denied by saved preference`, + }; + } + + const yoloMode = await this.resolveYoloMode( + toolsetName, + displayNameSuffix, + projectId, + ); + + if (yoloMode) { + // YOLO mode - execute without asking (unless always_deny above). + const resultContent = await toolset.executeTool( + displayNameSuffix, + toolCall.args as Record, + ); + + return { + id: toolCall.id, + content: resultContent, + }; + } + if (permissionCheck.shouldAsk) { // Create a permission request const permissionRequest: ToolPermissionRequest = { @@ -126,12 +165,6 @@ export class ToolsetsManager { content: `Tool execution denied by user`, }; } - } else if (!permissionCheck.isAllowed) { - // Permission is always_deny - return { - id: toolCall.id, - content: `Tool execution denied by saved preference`, - }; } // Permission granted, execute the tool 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/CostAPI.ts b/src/core/chorus/api/CostAPI.ts index ed2cc371..adc14589 100644 --- a/src/core/chorus/api/CostAPI.ts +++ b/src/core/chorus/api/CostAPI.ts @@ -34,6 +34,7 @@ export async function fetchOpenRouterCost( cost: number; promptTokens: number; completionTokens: number; + actualModel?: string; } | null> { try { const response = await fetch( @@ -63,6 +64,7 @@ export async function fetchOpenRouterCost( completionTokens: data.data.native_tokens_completion ?? data.data.tokens_completion, + actualModel: data.data.model, }; } catch (error) { console.error("Error fetching OpenRouter generation cost:", error); diff --git a/src/core/chorus/api/MessageAPI.ts b/src/core/chorus/api/MessageAPI.ts index 013cd067..2fb1e980 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 @@ -115,6 +126,7 @@ export interface MessageDBRow { completion_tokens: number | null; total_tokens: number | null; cost_usd: number | null; + actual_model_id: string | null; } export interface MessagePartDBRow { @@ -153,6 +165,7 @@ export function readMessage( completionTokens: row.completion_tokens ?? undefined, totalTokens: row.total_tokens ?? undefined, costUsd: row.cost_usd ?? undefined, + actualModelId: row.actual_model_id ?? undefined, }; } @@ -320,6 +333,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 +682,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 +898,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 +981,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 +1038,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 +1079,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, @@ -1125,31 +1307,50 @@ export function useStreamMessagePart() { const hasToolCalls = toolCalls && toolCalls.length > 0; - // Calculate cost - use OpenRouter's actual cost when available + // Calculate cost - use OpenRouter's response-level cost when available let costUsd: number | undefined; let actualPromptTokens = usageData?.prompt_tokens; let actualCompletionTokens = usageData?.completion_tokens; + let actualModelId: string | undefined; - // For OpenRouter models with generation ID, fetch actual costs - if ( - usageData?.generation_id && - modelConfig.modelId.startsWith("openrouter::") && - apiKeys.openrouter - ) { - const openRouterCost = await fetchOpenRouterCost( - usageData.generation_id, - apiKeys.openrouter, - ); - if (openRouterCost) { - costUsd = openRouterCost.cost; - // Use native token counts from OpenRouter - actualPromptTokens = openRouterCost.promptTokens; - actualCompletionTokens = - openRouterCost.completionTokens; + if (modelConfig.modelId.startsWith("openrouter::")) { + // Prefer cost and model from the streaming response + if (usageData?.cost !== undefined && usageData.cost >= 0) { + costUsd = usageData.cost; + } + if (usageData?.model) { + actualModelId = `openrouter::${usageData.model}`; + } + + // Fall back to generation endpoint if response didn't include cost or model + if ( + (costUsd === undefined || + actualModelId === undefined) && + usageData?.generation_id && + apiKeys.openrouter + ) { + const openRouterCost = await fetchOpenRouterCost( + usageData.generation_id, + apiKeys.openrouter, + ); + if (openRouterCost) { + if (costUsd === undefined) { + costUsd = openRouterCost.cost; + } + actualPromptTokens = openRouterCost.promptTokens; + actualCompletionTokens = + openRouterCost.completionTokens; + if ( + actualModelId === undefined && + openRouterCost.actualModel + ) { + actualModelId = `openrouter::${openRouterCost.actualModel}`; + } + } } } - // Fallback to calculated cost for non-OpenRouter or if fetch failed + // Fallback to calculated cost for non-OpenRouter or if no cost yet if ( costUsd === undefined && usageData?.prompt_tokens !== undefined && @@ -1198,8 +1399,9 @@ export function useStreamMessagePart() { actualPromptTokens !== undefined && actualCompletionTokens !== undefined; const hasCost = costUsd !== undefined; + const hasActualModelId = actualModelId !== undefined; - if (hasTokens || hasCost) { + if (hasTokens || hasCost || hasActualModelId) { // Build SET clause dynamically to avoid writing 0 for unknown values // Use a helper to track the next parameter index (1-based for SQL) let paramIndex = 1; @@ -1232,6 +1434,12 @@ export function useStreamMessagePart() { ); params.push(costUsd); } + if (actualModelId !== undefined) { + setClauses.push( + `actual_model_id = $${paramIndex++}`, + ); + params.push(actualModelId); + } // Add WHERE clause parameters (increment both for consistency, even though // paramIndex isn't used after this - makes the code more obviously correct) @@ -1285,6 +1493,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 +1503,7 @@ export function useStreamMessagePart() { })), isInProject: project.id !== "default", universalSystemPrompt: appMetadata["universal_system_prompt"], + promptProfileSystemPrompt, }); const customBaseUrl = await getCustomBaseUrl(); @@ -1364,9 +1575,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); @@ -1450,31 +1664,50 @@ export function useStreamMessageLegacy() { streamingToken, ); - // Calculate cost - use OpenRouter's actual cost when available + // Calculate cost - use OpenRouter's response-level cost when available let costUsd: number | undefined; let actualPromptTokens = usageData?.prompt_tokens; let actualCompletionTokens = usageData?.completion_tokens; + let actualModelId: string | undefined; - // For OpenRouter models with generation ID, fetch actual costs - if ( - usageData?.generation_id && - modelConfig.modelId.startsWith("openrouter::") && - apiKeys.openrouter - ) { - const openRouterCost = await fetchOpenRouterCost( - usageData.generation_id, - apiKeys.openrouter, - ); - if (openRouterCost) { - costUsd = openRouterCost.cost; - // Use native token counts from OpenRouter - actualPromptTokens = openRouterCost.promptTokens; - actualCompletionTokens = - openRouterCost.completionTokens; + if (modelConfig.modelId.startsWith("openrouter::")) { + // Prefer cost and model from the streaming response + if (usageData?.cost !== undefined && usageData.cost >= 0) { + costUsd = usageData.cost; + } + if (usageData?.model) { + actualModelId = `openrouter::${usageData.model}`; + } + + // Fall back to generation endpoint if response didn't include cost or model + if ( + (costUsd === undefined || + actualModelId === undefined) && + usageData?.generation_id && + apiKeys.openrouter + ) { + const openRouterCost = await fetchOpenRouterCost( + usageData.generation_id, + apiKeys.openrouter, + ); + if (openRouterCost) { + if (costUsd === undefined) { + costUsd = openRouterCost.cost; + } + actualPromptTokens = openRouterCost.promptTokens; + actualCompletionTokens = + openRouterCost.completionTokens; + if ( + actualModelId === undefined && + openRouterCost.actualModel + ) { + actualModelId = `openrouter::${openRouterCost.actualModel}`; + } + } } } - // Fallback to calculated cost for non-OpenRouter or if fetch failed + // Fallback to calculated cost for non-OpenRouter or if no cost yet if ( costUsd === undefined && usageData?.prompt_tokens !== undefined && @@ -1501,7 +1734,7 @@ export function useStreamMessageLegacy() { await db.execute( `UPDATE messages SET streaming_token = NULL, state = 'idle', text = ?, - prompt_tokens = ?, completion_tokens = ?, total_tokens = ?, cost_usd = ? + prompt_tokens = ?, completion_tokens = ?, total_tokens = ?, cost_usd = ?, actual_model_id = ? WHERE id = ? AND streaming_token = ?`, [ finalText, @@ -1509,6 +1742,7 @@ export function useStreamMessageLegacy() { actualCompletionTokens ?? null, totalTokens, costUsd ?? null, + actualModelId ?? null, messageId, streamingToken, ], @@ -1551,6 +1785,14 @@ export function useStreamMessageLegacy() { WHERE id = $2 AND streaming_token = $3`, [errorMessage, messageId, streamingToken], ); + + const projectId = await updateChatAndProjectCosts(chatId); + await queryClient.invalidateQueries(chatQueries.list()); + await queryClient.invalidateQueries(chatQueries.detail(chatId)); + if (projectId) { + await queryClient.invalidateQueries(projectQueries.list()); + } + UpdateQueue.getInstance().closeUpdateStream(streamKey); // invalidate to ensure consistency @@ -1828,6 +2070,7 @@ function useStopMessageStreaming() { return useMutation({ mutationKey: ["stopMessageStreaming"] as const, mutationFn: async ({ + chatId, messageId, streamingToken, errorMessage, @@ -1844,6 +2087,13 @@ function useStopMessageStreaming() { WHERE id = $2 AND streaming_token = $3`, [errorMessage, messageId, streamingToken], ); + + const projectId = await updateChatAndProjectCosts(chatId); + await queryClient.invalidateQueries(chatQueries.list()); + await queryClient.invalidateQueries(chatQueries.detail(chatId)); + if (projectId) { + await queryClient.invalidateQueries(projectQueries.list()); + } } else { await db.execute( `UPDATE messages @@ -1910,6 +2160,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 +2804,7 @@ function useStreamToolsMessage() { const stopMessageStreaming = useStopMessageStreaming(); const getToolsets = useGetToolsets(); const getProjectContext = useGetProjectContextLLMMessage(); + const { isQuickChatWindow } = useAppContext(); return useMutation({ mutationKey: ["streamToolsMessage"] as const, @@ -2503,12 +2814,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 +2863,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 +2895,10 @@ function useStreamToolsMessage() { streamingToken, }); - const toolsets = await getToolsets(); + const toolsets = + isQuickChatWindow || disableTools + ? [] + : await getToolsets(); const tools = toolsets.flatMap((toolset) => { return toolset.listTools(); }); @@ -2619,6 +2945,7 @@ function useStreamToolsMessage() { ToolsetsManager.instance.executeToolCall( toolCall, modelConfig.displayName, + projectId, ), ), )), @@ -2690,11 +3017,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 +3043,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 +3106,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 +3150,11 @@ function usePopulateToolsBlock(chatId: string) { messageId: createMessageResult.messageId, modelConfig, streamingToken: createMessageResult.streamingToken, + disableTools: + toolsDisabledActions.isToolsDisabledForModel( + chatId, + modelConfig.id, + ), }); }), ); @@ -2805,10 +3191,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 +3224,8 @@ export function usePopulateBlock(chatId: string, isQuickChatWindow: boolean) { previousMessageSets, isQuickChatWindow, replyToModelId, + excludedModelIds, + applyChatCreationModelDefaults, }); } default: { @@ -3088,6 +3480,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 +3527,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 +3770,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..03c26644 100644 --- a/src/core/chorus/api/ProjectAPI.ts +++ b/src/core/chorus/api/ProjectAPI.ts @@ -45,6 +45,10 @@ export type Project = { isImported: boolean; // Cost tracking totalCostUsd?: number; + /** Per-project default prompt profile; overrides the global default when set. */ + defaultPromptProfileId?: string; + /** Per-project YOLO override. undefined = inherit global, true = force on, false = force off. */ + yoloMode?: boolean; }; export type Projects = { @@ -63,6 +67,8 @@ type ProjectDBRow = { context_text?: string; is_imported: number; total_cost_usd: number | null; + default_prompt_profile_id: string | null; + yolo_mode: number | null; }; function readProject(row: ProjectDBRow): Project { @@ -76,13 +82,15 @@ 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, + yoloMode: row.yolo_mode === null ? undefined : row.yolo_mode === 1, }; } 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, yolo_mode FROM projects ORDER BY updated_at DESC`, ) @@ -115,7 +123,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, yolo_mode FROM projects WHERE id = ?", [projectId], ); if (rows.length === 0) { @@ -641,6 +649,69 @@ 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), + ); + }, + }); +} + +/** Non-hook async fetch of a project's yolo_mode for use in ToolsetsManager */ +export async function fetchProjectYoloMode( + projectId: string, +): Promise<boolean | undefined> { + const rows = await db.select<{ yolo_mode: number | null }[]>( + "SELECT yolo_mode FROM projects WHERE id = ?", + [projectId], + ); + if (rows.length === 0) return undefined; + const value = rows[0].yolo_mode; + return value === null ? undefined : value === 1; +} + +export function useSetProjectYoloMode() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["setProjectYoloMode"] as const, + mutationFn: async ({ + projectId, + yoloMode, + }: { + projectId: string; + yoloMode: boolean | null; + }) => { + await db.execute("UPDATE projects SET yolo_mode = ? WHERE id = ?", [ + yoloMode === null ? null : yoloMode ? 1 : 0, + 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..5a68982c --- /dev/null +++ b/src/core/chorus/api/ProviderVisibilityAPI.ts @@ -0,0 +1,151 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { db } from "../DB"; +import { ProviderVisibility, ProviderName, getProviderName } from "../Models"; +import { useApiKeys } from "./AppMetadataAPI"; +import { useModelConfigs } from "./ModelsAPI"; +import { hasApiKey } from "@core/utilities/ProxyUtils"; + +const providerVisibilityKeys = { + all: () => ["providerVisibility"] as const, + list: () => [...providerVisibilityKeys.all(), "list"] as const, +}; + +const API_KEY_REQUIRED_HIDDEN_PROVIDERS = new Set<ProviderName>([ + "openrouter", + "google", + "openai", + "anthropic", +]); + +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(); + const { data: apiKeys } = useApiKeys(); + const { data: allModels } = useModelConfigs(); + + if (!data && !allModels) return undefined; + + const visibilityMap = new Map( + (data ?? []).map((v) => [v.modelId, v.isVisible]), + ); + + if (!allModels || apiKeys === undefined) { + return visibilityMap; + } + + for (const model of allModels) { + const provider = getProviderName(model.modelId); + if (!API_KEY_REQUIRED_HIDDEN_PROVIDERS.has(provider)) { + continue; + } + + if (!hasApiKey(provider as keyof typeof apiKeys, apiKeys)) { + visibilityMap.set(model.modelId, false); + } + } + + return visibilityMap; +} diff --git a/src/core/chorus/api/ToolYoloAPI.ts b/src/core/chorus/api/ToolYoloAPI.ts new file mode 100644 index 00000000..c31baeec --- /dev/null +++ b/src/core/chorus/api/ToolYoloAPI.ts @@ -0,0 +1,101 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { db } from "../DB"; + +export const toolYoloKeys = { + toolYolos: () => ["tool_yolo"] as const, + toolYolo: (toolsetName: string, toolName: string) => + [...toolYoloKeys.toolYolos(), toolsetName, toolName] as const, +}; + +export type ToolYoloEntry = { + toolsetName: string; + toolName: string; +}; + +type ToolYoloDBRow = { + toolset_name: string; + tool_name: string; + created_at: string; +}; + +function readToolYolo(row: ToolYoloDBRow): ToolYoloEntry { + return { + toolsetName: row.toolset_name, + toolName: row.tool_name, + }; +} + +export async function fetchAllToolYolo(): Promise<ToolYoloEntry[]> { + const rows = await db.select<ToolYoloDBRow[]>( + "SELECT * FROM tool_yolo ORDER BY toolset_name, tool_name", + ); + return rows.map(readToolYolo); +} + +/** Non-hook version for use inside ToolsetsManager */ +export async function checkToolYolo( + toolsetName: string, + toolName: string, +): Promise<boolean> { + const rows = await db.select<{ exists: number }[]>( + "SELECT 1 AS exists FROM tool_yolo WHERE toolset_name = ? AND tool_name = ?", + [toolsetName, toolName], + ); + return rows.length > 0; +} + +export function useAllToolYolo(enabled = true) { + return useQuery({ + queryKey: toolYoloKeys.toolYolos(), + queryFn: fetchAllToolYolo, + enabled, + }); +} + +export function useSetToolYolo() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["setToolYolo"] as const, + mutationFn: async ({ + toolsetName, + toolName, + }: { + toolsetName: string; + toolName: string; + }) => { + await db.execute( + "INSERT OR IGNORE INTO tool_yolo (toolset_name, tool_name) VALUES (?, ?)", + [toolsetName, toolName], + ); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: toolYoloKeys.toolYolos(), + }); + }, + }); +} + +export function useDeleteToolYolo() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["deleteToolYolo"] as const, + mutationFn: async ({ + toolsetName, + toolName, + }: { + toolsetName: string; + toolName: string; + }) => { + await db.execute( + "DELETE FROM tool_yolo WHERE toolset_name = ? AND tool_name = ?", + [toolsetName, toolName], + ); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: toolYoloKeys.toolYolos(), + }); + }, + }); +} 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/ProxyUtils.ts b/src/core/utilities/ProxyUtils.ts index 7f3a48eb..ec7ad395 100644 --- a/src/core/utilities/ProxyUtils.ts +++ b/src/core/utilities/ProxyUtils.ts @@ -39,7 +39,8 @@ export function hasApiKey( providerKey: keyof ApiKeys, apiKeys: ApiKeys, ): boolean { - return Boolean(apiKeys[providerKey]); + const key = apiKeys[providerKey]; + return typeof key === "string" && key.trim().length > 0; } /** 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..edf27177 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(); @@ -290,6 +301,7 @@ function Project({ projectId }: { projectId: string }) { const project = projects.find((p) => p.id === projectId)!; const isCollapsed = project?.isCollapsed || false; const showCost = settings?.showCost ?? false; + const projectTotalCostUsd = Number(project?.totalCostUsd ?? 0); const handleToggleCollapse = (e: React.MouseEvent) => { e.preventDefault(); @@ -352,10 +364,10 @@ function Project({ projectId }: { projectId: string }) { {projectDisplayName(project?.name)} </h2> {showCost && - project?.totalCostUsd !== undefined && - project.totalCostUsd > 0 && ( + Number.isFinite(projectTotalCostUsd) && + projectTotalCostUsd > 0 && ( <span className="ml-auto pr-8 text-xs text-muted-foreground font-normal flex-shrink-0"> - {formatCost(project.totalCostUsd)} + {formatCost(projectTotalCostUsd)} </span> )} </span> @@ -431,15 +443,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 +797,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 && ( <> @@ -946,6 +1211,7 @@ const ChatListItemView = React.memo( chatCost, showCost, }: ChatListItemViewProps) => { + const chatTotalCostUsd = Number(chatCost ?? 0); return ( <div key={chatId + "-sidebar"} @@ -1008,10 +1274,10 @@ const ChatListItemView = React.memo( /> <ChatLoadingIndicator chatId={chatId} /> {showCost && - chatCost !== undefined && - chatCost > 0 && ( + Number.isFinite(chatTotalCostUsd) && + chatTotalCostUsd > 0 && ( <span className="ml-auto pl-2 text-xs text-muted-foreground flex-shrink-0"> - {formatCost(chatCost)} + {formatCost(chatTotalCostUsd)} </span> )} </div> diff --git a/src/ui/components/AutoExpandingTextarea.tsx b/src/ui/components/AutoExpandingTextarea.tsx index 17174947..919a02a0 100644 --- a/src/ui/components/AutoExpandingTextarea.tsx +++ b/src/ui/components/AutoExpandingTextarea.tsx @@ -7,45 +7,58 @@ interface AutoExpandingTextareaProps value: string; onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void; autoFocus?: boolean; + onHeightChange?: (height: number) => void; } const AutoExpandingTextarea = forwardRef< HTMLTextAreaElement, AutoExpandingTextareaProps ->(({ value, onChange, className, autoFocus, ...props }, ref) => { - const adjustHeight = useCallback((element: HTMLTextAreaElement | null) => { - if (element) { - const scrollTop = window.scrollY; +>( + ( + { value, onChange, className, autoFocus, onHeightChange, ...props }, + ref, + ) => { + const adjustHeight = useCallback( + (element: HTMLTextAreaElement | null) => { + if (element) { + const scrollTop = window.scrollY; - element.style.height = "auto"; - element.style.height = `${element.scrollHeight}px`; + element.style.height = "auto"; + element.style.height = `${element.scrollHeight}px`; + onHeightChange?.(element.scrollHeight); - // Restore scroll position once state changes - window.scrollTo(0, scrollTop); - } - }, []); - - return ( - <Textarea - rows={1} - ref={(node) => { - adjustHeight(node); - if (typeof ref === "function") { - ref(node); - } else if (ref) { - ref.current = node; + // Restore scroll position once state changes + window.scrollTo(0, scrollTop); } - }} - value={value} - onChange={(e) => { - onChange(e); - adjustHeight(e.target); - }} - autoFocus={autoFocus} - className={cn("resize-none overflow-hidden min-h-0 p-0", className)} - {...props} - /> - ); -}); + }, + [onHeightChange], + ); + + return ( + <Textarea + rows={1} + ref={(node) => { + adjustHeight(node); + if (typeof ref === "function") { + ref(node); + } else if (ref) { + ref.current = node; + } + }} + value={value} + onChange={(e) => { + onChange(e); + adjustHeight(e.target); + }} + autoFocus={autoFocus} + className={cn( + "resize-none overflow-hidden min-h-0 p-0", + className, + )} + {...props} + /> + ); + }, +); export default AutoExpandingTextarea; diff --git a/src/ui/components/ChatInput.tsx b/src/ui/components/ChatInput.tsx index 1892f80c..f51e3fe9 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 { @@ -32,20 +32,28 @@ import { } from "@ui/hooks/useAttachments"; import { dialogActions, useDialogStore } from "@core/infra/DialogStore"; import { ChatSuggestions } from "./ChatSuggestions"; -import { ArrowUp, ChevronDownIcon } from "lucide-react"; +import { ArrowUp, ChevronDownIcon, ChevronUp } from "lucide-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; 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"; +const COLLAPSE_THRESHOLD_PX = 150; +const COLLAPSED_HEIGHT_PX = 80; + function ScrollToBottomButton({ onClick }: { onClick: () => void }) { const { isQuickChatWindow } = useAppContext(); @@ -75,6 +83,7 @@ export function ChatInput({ defaultReplyToModel, showScrollButton, handleScrollToBottom, + minimizedModels, }: { chatId: string; isNewChat: boolean | undefined; @@ -87,10 +96,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 +161,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], @@ -158,15 +182,18 @@ export function ChatInput({ const [isAnimatingToBottom, setIsAnimatingToBottom] = useState(false); + const [naturalHeight, setNaturalHeight] = useState(0); + const [isCollapsed, setIsCollapsed] = useState(false); + const [isFocused, setIsFocused] = useState(false); + const placeholderText = isReply ? "Reply..." : "Ask me anything..."; const settings = useSettings(); 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 +334,9 @@ export function ChatInput({ setIsAnimatingToBottom(true); } + const applyChatCreationModelDefaults = + !isReply && isNewChat === true; + // Convert attachments await convertDraftAttachmentsToMessageAttachments.mutateAsync({ chatId, @@ -337,7 +367,9 @@ export function ChatInput({ void populateBlock.mutateAsync({ messageSetId: aiMessageSetId, blockType: BLOCK_TYPE, - replyToModelId: replyToModelConfig?.modelId, + replyToModelId: replyToModelConfig?.id, + excludedModelIds: minimizedModels, + applyChatCreationModelDefaults, }); }, }); @@ -363,49 +395,89 @@ export function ChatInput({ } }; + const handleInputFocus = useCallback(() => { + setIsFocused(true); + setIsCollapsed(false); + inputActions.setFocusedInputId( + isReply ? REPLY_CHAT_INPUT_ID : DEFAULT_CHAT_INPUT_ID, + ); + }, [isReply]); + + const handleInputBlur = useCallback(() => { + setIsFocused(false); + inputActions.setFocusedInputId(null); + }, []); + + useEffect(() => { + if (naturalHeight < COLLAPSE_THRESHOLD_PX) { + setIsCollapsed(false); + } else if (!isFocused) { + setIsCollapsed(true); + } + }, [naturalHeight, isFocused]); + + useEffect(() => { + if (isCollapsed && inputRef.current) { + inputRef.current.scrollTop = inputRef.current.scrollHeight; + } + }, [isCollapsed, inputRef]); + // -------------------------------------------------------------------------- - // 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 +494,7 @@ export function ChatInput({ } }, [ - selectedModelConfigsCompare, + chatCompareModelConfigs, ensureCompareModelConfigSelected, ensureCompareModelConfigDeselected, ], @@ -430,14 +502,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(() => { @@ -556,43 +679,75 @@ export function ChatInput({ onSubmit={handleSubmit} className="flex flex-col w-full mx-auto relative" > - <AutoExpandingTextarea - ref={inputRef} - value={draft} - onChange={(e) => { - setDraft(e.target.value); - }} - onPaste={(e) => void handlePaste(e)} - rows={2} - onKeyDown={(e) => { - if (cautiousEnter) { - // Cautious mode: Cmd+Enter to submit - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - handleSubmit(e); - } - } else { - // Normal mode: Enter to submit, Shift+Enter for newline - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSubmit(e); + <div className="relative"> + <AutoExpandingTextarea + ref={inputRef} + value={draft} + onChange={(e) => { + setDraft(e.target.value); + }} + onPaste={(e) => void handlePaste(e)} + rows={2} + onKeyDown={(e) => { + if (cautiousEnter) { + // Cautious mode: Cmd+Enter to submit + if ( + e.key === "Enter" && + (e.metaKey || e.ctrlKey) + ) { + e.preventDefault(); + handleSubmit(e); + } + } else { + // Normal mode: Enter to submit, Shift+Enter for newline + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e); + } } + }} + placeholder={placeholderText} + className="ring-0 + placeholder:text-muted-foreground/50 font-[350] focus:outline-none pt-2 px-1.5 select-text + max-h-[60vh] overflow-y-auto my-2 rounded-none !p-0" + autoFocus + onFocus={handleInputFocus} + onBlur={handleInputBlur} + onHeightChange={setNaturalHeight} + style={ + isCollapsed + ? { + maxHeight: `${COLLAPSED_HEIGHT_PX}px`, + overflowY: "hidden", + } + : undefined } - }} - placeholder={placeholderText} - className="ring-0 - placeholder:text-muted-foreground/50 font-[350] focus:outline-none pt-2 px-1.5 select-text - max-h-[60vh] overflow-y-auto my-2 rounded-none !p-0" - autoFocus - onFocus={() => - inputActions.setFocusedInputId( - isReply - ? REPLY_CHAT_INPUT_ID - : DEFAULT_CHAT_INPUT_ID, - ) - } - onBlur={() => inputActions.setFocusedInputId(null)} - /> + /> + {isCollapsed && ( + <div className="absolute top-0 left-0 right-0 h-8 bg-gradient-to-b from-background to-transparent pointer-events-none" /> + )} + {naturalHeight >= COLLAPSE_THRESHOLD_PX && ( + <button + type="button" + onClick={() => { + setIsCollapsed((c) => !c); + if (isCollapsed) inputRef.current?.focus(); + }} + className="absolute top-1 right-1 z-10 flex items-center gap-0.5 text-xs text-muted-foreground/50 bg-background/90 backdrop-blur-[1px] rounded-full px-2 py-1 hover:text-muted-foreground" + > + {isCollapsed ? ( + <> + Show more <ChevronUp className="w-3 h-3" /> + </> + ) : ( + <> + Collapse{" "} + <ChevronDownIcon className="w-3 h-3" /> + </> + )} + </button> + )} + </div> {/* Helper text for Cmd+L */} {isNextFocus && ( @@ -603,13 +758,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 +778,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 +832,14 @@ export function ChatInput({ onToggleModelConfig: (id) => void toggleCompareModelConfig(id), onClearModelConfigs: clearCompareModelConfigs, + onSelectAllModelConfigs: + selectAllCompareModelConfigs, + onUnionSelectAllVisibleModelConfigs: + unionSelectAllCompareModelConfigs, + selectedModelConfigsForChat: + chatCompareModelConfigs, + onReorderSelectedModelConfigs: + reorderMainChatCompare, }} /> )} @@ -693,10 +855,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, }); } }, @@ -756,42 +917,72 @@ export function ChatInput({ removeAttachment.mutate({ attachmentId }) } /> - <AutoExpandingTextarea - ref={inputRef} - value={draft} - onChange={(e) => { - setDraft(e.target.value); - }} - onPaste={(e) => handlePaste(e)} - rows={2} - onKeyDown={(e) => { - if (cautiousEnter) { - // Cautious mode: Cmd+Enter to submit - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - handleSubmit(e); - } - } else { - // Normal mode: Enter to submit, Shift+Enter for newline - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSubmit(e); + <div className="relative"> + <AutoExpandingTextarea + ref={inputRef} + value={draft} + onChange={(e) => { + setDraft(e.target.value); + }} + onPaste={(e) => handlePaste(e)} + rows={2} + onKeyDown={(e) => { + if (cautiousEnter) { + // Cautious mode: Cmd+Enter to submit + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSubmit(e); + } + } else { + // Normal mode: Enter to submit, Shift+Enter for newline + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e); + } } + }} + placeholder={placeholderText} + className={`ring-0 w-full rounded-xl bg-foreground/5 focus:shadow-sm + placeholder:text-foreground/50 px-3 !border-foreground/10 select-text + max-h-[70vh] overflow-y-auto !p-2`} + autoFocus + onFocus={handleInputFocus} + onBlur={handleInputBlur} + onHeightChange={setNaturalHeight} + style={ + isCollapsed + ? { + maxHeight: `${COLLAPSED_HEIGHT_PX}px`, + overflowY: "hidden", + } + : undefined } - }} - placeholder={placeholderText} - className={`ring-0 w-full rounded-xl bg-foreground/5 focus:shadow-sm - placeholder:text-foreground/50 px-3 !border-foreground/10 select-text - max-h-[70vh] overflow-y-auto !p-2`} - autoFocus - onFocus={() => - inputActions.setFocusedInputId( - isReply ? REPLY_CHAT_INPUT_ID : DEFAULT_CHAT_INPUT_ID, - ) - } - onBlur={() => inputActions.setFocusedInputId(null)} - tabIndex={1} // should be first item to get focus - /> + tabIndex={1} // should be first item to get focus + /> + {isCollapsed && ( + <div className="absolute top-0 left-0 right-0 h-8 bg-gradient-to-b from-background to-transparent pointer-events-none" /> + )} + {naturalHeight >= COLLAPSE_THRESHOLD_PX && ( + <button + type="button" + onClick={() => { + setIsCollapsed((c) => !c); + if (isCollapsed) inputRef.current?.focus(); + }} + className="absolute top-1 right-1 z-10 flex items-center gap-0.5 text-xs text-muted-foreground/50 bg-background/90 backdrop-blur-[1px] rounded-full px-2 py-1 hover:text-muted-foreground" + > + {isCollapsed ? ( + <> + Show more <ChevronUp className="w-3 h-3" /> + </> + ) : ( + <> + Collapse <ChevronDownIcon className="w-3 h-3" /> + </> + )} + </button> + )} + </div> {/* Helper text for Cmd+L */} {isNextFocus && ( 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..bfc68e40 100644 --- a/src/ui/components/ManageModelsBox.tsx +++ b/src/ui/components/ManageModelsBox.tsx @@ -18,6 +18,7 @@ import { import { useNavigate } from "react-router-dom"; import { ModelConfig, + ProviderName, getProviderLabel, getProviderName, } from "@core/chorus/Models"; @@ -47,19 +48,159 @@ 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[]) => { - if (searchTerms.length === 0) return models; - return models.filter((m) => { - const providerLabel = getProviderLabel(m.modelId); - - return searchTerms.every( - (term) => - m.displayName.toLowerCase().includes(term) || - providerLabel.toLowerCase().includes(term), +const normalizeSearchValue = (value: string): string => + value.toLowerCase().replace(/[^a-z0-9]/g, ""); + +const ensureKnownProvidersExhaustive = <T extends readonly ProviderName[]>( + providers: T & + (Exclude<ProviderName, T[number]> extends never + ? unknown + : "KNOWN_PROVIDERS must include every ProviderName"), +): T => providers; + +// Keep this curated list aligned with ProviderName for provider-prefix parsing. +const KNOWN_PROVIDERS = ensureKnownProvidersExhaustive([ + "anthropic", + "openai", + "google", + "perplexity", + "grok", + "ollama", + "lmstudio", + "openrouter", + "meta", +] as const); + +interface ParsedSearchQuery { + providerFilter: ProviderName | null; + modelTerms: string[]; +} + +const parseSearchQuery = (query: string): ParsedSearchQuery => { + const colonIndex = query.indexOf(":"); + if (colonIndex !== -1) { + const potentialProvider = normalizeSearchValue( + query.slice(0, colonIndex), ); - }); + if (potentialProvider.length > 0) { + // Exact match first + const exactMatch = KNOWN_PROVIDERS.find( + (p) => p === potentialProvider, + ); + if (exactMatch) { + const remainder = query.slice(colonIndex + 1).toLowerCase(); + const modelTerms = remainder.split(" ").filter(Boolean); + return { providerFilter: exactMatch, modelTerms }; + } + // Unambiguous prefix match + const prefixMatches = KNOWN_PROVIDERS.filter((p) => + p.startsWith(potentialProvider), + ); + if (prefixMatches.length === 1) { + const remainder = query.slice(colonIndex + 1).toLowerCase(); + const modelTerms = remainder.split(" ").filter(Boolean); + return { providerFilter: prefixMatches[0], modelTerms }; + } + } + } + const modelTerms = query.toLowerCase().split(" ").filter(Boolean); + return { providerFilter: null, modelTerms }; +}; + +/** Returns a match score > 0 if term matches haystack, 0 for no match. */ +const scoreMatch = (term: string, haystack: string): number => { + if (!term) return 100; + + // Exact substring match + if (haystack.includes(term)) return 100; + + // Word-boundary match: term matches the start of any whitespace-separated word + const words = haystack.split(/[\s\-_.:/]+/); + if (words.some((word) => word.startsWith(term))) return 80; + + const normalizedTerm = normalizeSearchValue(term); + const normalizedHaystack = normalizeSearchValue(haystack); + + if (!normalizedTerm) return 0; + + // For purely numeric terms, require contiguous match in number groups + if (/^\d+$/.test(normalizedTerm)) { + const numberGroups = normalizedHaystack.match(/\d+/g) ?? []; + const contiguous = numberGroups.some( + (group) => + group === normalizedTerm || group.startsWith(normalizedTerm), + ); + return contiguous ? 60 : 0; + } + + // Normalized substring match (strip non-alphanumeric) + if (normalizedHaystack.includes(normalizedTerm)) return 60; + + return 0; +}; + +const filterBySearch = ( + models: ModelConfig[], + modelTerms: string[], + providerFilter: ProviderName | null = null, +): ModelConfig[] => { + if (modelTerms.length === 0 && providerFilter === null) return models; + + // Compute score once per model to avoid redundant work in the sort comparator + const scored = models.reduce<{ model: ModelConfig; score: number }[]>( + (acc, model) => { + if ( + providerFilter !== null && + getProviderName(model.modelId) !== providerFilter + ) { + return acc; + } + + if (modelTerms.length === 0) { + acc.push({ model, score: 0 }); + return acc; + } + + const providerLabel = getProviderLabel(model.modelId); + const haystack = + `${model.displayName} ${providerLabel} ${model.modelId}`.toLowerCase(); + const termScores = modelTerms.map((term) => + scoreMatch(term, haystack), + ); + + if (termScores.some((s) => s <= 0)) return acc; + + acc.push({ + model, + score: termScores.reduce((total, s) => total + s, 0), + }); + return acc; + }, + [], + ); + + if (modelTerms.length > 0) { + scored.sort((a, b) => b.score - a.score); + } + + return scored.map(({ model }) => model); }; // Helper function to format pricing for display (per million tokens) @@ -97,9 +238,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 +444,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 +508,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 +569,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 +661,20 @@ 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 { providerFilter, modelTerms } = parseSearchQuery(searchQuery); + + 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"; @@ -510,22 +696,30 @@ export function ManageModelsBox({ const directByProvider = Object.fromEntries( directProviders.map((provider) => [ provider, - filterBySearch( - systemModels.filter( - (m) => getProviderName(m.modelId) === provider, - ), - searchTerms, - ), + // Short-circuit: if a different provider is specified, return empty + providerFilter !== null && providerFilter !== provider + ? [] + : filterBySearch( + systemModels.filter( + (m) => getProviderName(m.modelId) === provider, + ), + modelTerms, + providerFilter, + ), ]), ) as Record<(typeof directProviders)[number], ModelConfig[]>; return { - custom: filterBySearch(userModels, searchTerms), - local: filterBySearch(localModels, searchTerms), - openrouter: filterBySearch(openrouterModels, searchTerms), + custom: filterBySearch(userModels, modelTerms, providerFilter), + local: filterBySearch(localModels, modelTerms, providerFilter), + openrouter: filterBySearch( + openrouterModels, + modelTerms, + providerFilter, + ), directByProvider, }; - }, [modelConfigs.data, searchQuery]); + }, [modelConfigs.data, searchQuery, providerVisibilityMap, activeProfile]); useLayoutEffect(() => { if (!listRef.current) return; @@ -540,6 +734,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 +878,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..ce1c50e5 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> ); } @@ -1128,18 +1187,121 @@ export function ToolsReplyCountView({ ); } +/** + * Map a wire / persisted model id (e.g. OpenRouter snapshot slug) to a local {@link Models.ModelConfig}. + */ +function findModelConfigForDisplay( + configs: Models.ModelConfig[] | undefined, + resolvedId: string, +): Models.ModelConfig | undefined { + if (!configs?.length) { + return undefined; + } + const byId = configs.find((m) => m.id === resolvedId); + if (byId) { + return byId; + } + const byModelId = configs.find((m) => m.modelId === resolvedId); + if (byModelId) { + return byModelId; + } + if (resolvedId.startsWith("openrouter::")) { + const hyphenMatch = configs.find( + (m) => + m.modelId.startsWith("openrouter::") && + (resolvedId.startsWith(`${m.modelId}-`) || + m.modelId.startsWith(`${resolvedId}-`)), + ); + if (hyphenMatch) { + return hyphenMatch; + } + if (resolvedId.endsWith(":free")) { + const withoutFree = resolvedId.slice( + 0, + resolvedId.length - ":free".length, + ); + const byNoFreeSuffix = configs.find( + (m) => m.id === withoutFree || m.modelId === withoutFree, + ); + if (byNoFreeSuffix) { + return byNoFreeSuffix; + } + return configs.find( + (m) => + m.modelId.startsWith("openrouter::") && + (withoutFree.startsWith(`${m.modelId}-`) || + m.modelId.startsWith(`${withoutFree}-`)), + ); + } + } + return undefined; +} + +/** Part after the first {@code openrouter::} (OpenRouter API / catalog slug). */ +function getOpenRouterWireModelSlug(modelId: string): string | undefined { + if (!modelId.startsWith("openrouter::")) { + return undefined; + } + return modelId.slice("openrouter::".length); +} + +/** + * User-facing label when a request was routed (auto / free meta-model on OpenRouter). + */ +function openRouterRoutingBadgeText(requestedModelId: string): string { + const slug = getOpenRouterWireModelSlug(requestedModelId); + if (slug === "openrouter/free") { + return "VIA FREEROUTER"; + } + if (slug === "openrouter/auto") { + return "VIA AUTOROUTER"; + } + return "VIA ROUTER"; +} + +/** + * Whether the resolved model id refers to the same OpenRouter model as the one the user picked + * (API may append snapshot / version segments after a hyphen). + */ +function resolvedOpenRouterModelMatchesRequested( + resolved: string, + requestedModelId: string, +): boolean { + if (resolved === requestedModelId) { + return true; + } + if ( + !resolved.startsWith("openrouter::") || + !requestedModelId.startsWith("openrouter::") + ) { + return false; + } + return ( + resolved.startsWith(`${requestedModelId}-`) || + requestedModelId.startsWith(`${resolved}-`) + ); +} + export function ToolsMessageView({ message, isQuickChatWindow, 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 +1328,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 +1346,23 @@ export function ToolsMessageView({ const modelConfig = modelConfigsQuery.data?.find( (m) => m.id === message.model, ); + const displayModelId = message.actualModelId ?? message.model; + const displayModelConfig = findModelConfigForDisplay( + modelConfigsQuery.data, + displayModelId, + ); + const canonicalRequestedModelId = modelConfig?.modelId ?? message.model; + const isAutoRoutedModel = + message.actualModelId !== undefined && + !resolvedOpenRouterModelMatchesRequested( + message.actualModelId, + canonicalRequestedModelId, + ); + const routingBadgeText = isAutoRoutedModel + ? openRouterRoutingBadgeText(canonicalRequestedModelId) + : undefined; + const toolsDisabledForModel = + toolsDisabledByChatId.get(message.chatId)?.has(message.model) ?? false; const messageClasses = [ "relative", @@ -1189,13 +1371,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 +1398,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, @@ -1250,28 +1439,34 @@ export function ToolsMessageView({ : "text-muted-foreground" }`} > - {modelConfig && ( - <div className="flex items-center gap-2 h-6"> + <div className="flex items-center gap-2 h-6"> + {displayModelConfig && ( <ProviderLogo size="sm" - modelId={modelConfig.modelId} + modelId={ + displayModelConfig.modelId + } className="-mt-[1px]" /> - <div className="text-sm"> - {modelConfig?.displayName} - </div> + )} + <div className="text-sm"> + <span> + {displayModelConfig?.displayName ?? + displayModelId} + </span> + {routingBadgeText !== undefined && ( + <span className="ml-1 text-[10px] uppercase tracking-wider text-muted-foreground"> + {routingBadgeText} + </span> + )} + {toolsDisabledForModel && ( + <span className="ml-1 text-[10px] uppercase tracking-wider text-amber-700"> + tools off + </span> + )} </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> <div className={`no-print mr-3 flex items-center h-6 gap-2 @@ -1294,6 +1489,7 @@ export function ToolsMessageView({ messageId: message.id, }); + onStop?.(); }} > <StopCircleIcon className="w-3.5 h-3.5" /> @@ -1410,6 +1606,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 +1653,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 +1694,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 +2170,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 +2184,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 +2242,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 +2260,8 @@ const MessageSetView = memo( toolsBlock={messageSet.toolsBlock} isLastRow={isLastRow} isQuickChatWindow={isQuickChatWindow} + minimizedModels={minimizedModels} + onMinimize={onMinimize} /> ) : messageSet.selectedBlockType === "brainstorm" ? ( <BrainstormBlockView @@ -1722,8 +2312,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 +2381,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 +2499,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 +2545,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 +2760,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 +2807,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, + ); + 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, ); - return; + 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 +2967,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 +3335,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 +3353,7 @@ export default function MultiChat() { sentAttachmentTypes={sentAttachmentTypes} showScrollButton={showScrollButton} handleScrollToBottom={handleScrollToBottom} + minimizedModels={minimizedModels} /> </div> </ResizablePanel> @@ -2635,12 +3509,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 +3632,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 +3657,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/Onboarding.tsx b/src/ui/components/Onboarding.tsx index 44d00219..cd0038e5 100644 --- a/src/ui/components/Onboarding.tsx +++ b/src/ui/components/Onboarding.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useMemo } from "react"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; @@ -6,59 +6,123 @@ import { SettingsManager } from "@core/utilities/Settings"; import * as AppMetadataAPI from "@core/chorus/api/AppMetadataAPI"; import { useQueryClient } from "@tanstack/react-query"; +type OnboardingProvider = "openrouter" | "google" | "openai" | "anthropic"; + +const ONBOARDING_API_KEY_FIELDS: Array<{ + provider: OnboardingProvider; + label: string; + placeholder: string; +}> = [ + { + provider: "openrouter", + label: "OpenRouter API key", + placeholder: "sk-or-v1-...", + }, + { + provider: "google", + label: "Google AI API key", + placeholder: "AIza...", + }, + { + provider: "openai", + label: "OpenAI API key", + placeholder: "sk-...", + }, + { + provider: "anthropic", + label: "Anthropic API key", + placeholder: "sk-ant-...", + }, +]; + +const EMPTY_API_KEY_INPUTS: Record<OnboardingProvider, string> = { + openrouter: "", + google: "", + openai: "", + anthropic: "", +}; + export default function Onboarding({ onComplete }: { onComplete: () => void }) { const onboardingStep = AppMetadataAPI.useOnboardingStep(); const setOnboardingStep = AppMetadataAPI.useSetOnboardingStep(); - const [openRouterKey, setOpenRouterKey] = useState(""); + const [apiKeyInputs, setApiKeyInputs] = useState(EMPTY_API_KEY_INPUTS); const [isSaving, setIsSaving] = useState(false); const queryClient = useQueryClient(); + const hasAnyApiKey = useMemo( + () => + Object.values(apiKeyInputs).some( + (apiKey) => apiKey.trim().length > 0, + ), + [apiKeyInputs], + ); + const handleNextStep = useCallback(() => { setOnboardingStep.mutate({ step: 1 }); }, [setOnboardingStep]); const handleSaveAndComplete = useCallback(async () => { - if (openRouterKey.trim()) { - setIsSaving(true); + setIsSaving(true); + try { const settingsManager = SettingsManager.getInstance(); const currentSettings = await settingsManager.get(); - const newApiKeys = { - ...currentSettings.apiKeys, - openrouter: openRouterKey.trim(), - }; - await settingsManager.set({ + + const trimmedApiKeys: Partial<Record<OnboardingProvider, string>> = + {}; + for (const field of ONBOARDING_API_KEY_FIELDS) { + const value = apiKeyInputs[field.provider].trim(); + if (value.length > 0) { + trimmedApiKeys[field.provider] = value; + } + } + + const updatedSettings = { ...currentSettings, - apiKeys: newApiKeys, - }); + apiKeys: { + ...currentSettings.apiKeys, + ...trimmedApiKeys, + }, + }; + + await settingsManager.set(updatedSettings); await queryClient.invalidateQueries({ queryKey: ["apiKeys"] }); + } finally { setIsSaving(false); } + onComplete(); - }, [openRouterKey, queryClient, onComplete]); + }, [apiKeyInputs, queryClient, onComplete]); + + const handleApiKeyChange = useCallback( + (provider: OnboardingProvider, value: string) => { + setApiKeyInputs((previous) => ({ + ...previous, + [provider]: value, + })); + }, + [], + ); - // Allow pressing Enter to continue quickly useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter") { - e.preventDefault(); if (onboardingStep === 0) { handleNextStep(); - } else { + } else if (onboardingStep === 1 && !isSaving) { void handleSaveAndComplete(); } } }; document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [onboardingStep, handleNextStep, handleSaveAndComplete]); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [onboardingStep, handleNextStep, handleSaveAndComplete, isSaving]); if (onboardingStep === 0) { return ( - <div - data-tauri-drag-region - className="fixed inset-0 z-50 flex flex-col items-center justify-center min-h-screen bg-background/95 backdrop-blur-sm px-4" - > + <div className="min-h-screen flex items-center justify-center px-4"> <div className="text-center space-y-6 max-w-3xl w-full"> <div className="space-y-2"> <h1 className="text-2xl font-semibold tracking-tight"> @@ -82,44 +146,38 @@ export default function Onboarding({ onComplete }: { onComplete: () => void }) { ); } - // Step 2: OpenRouter API key return ( - <div - data-tauri-drag-region - className="fixed inset-0 z-50 flex flex-col items-center justify-center min-h-screen bg-background/95 backdrop-blur-sm px-4" - > - <div className="text-center space-y-6 max-w-md w-full"> - <div className="space-y-2"> - <h1 className="text-2xl font-semibold tracking-tight"> - Add an API Key - </h1> - <p className="text-muted-foreground"> - Chorus runs on API keys. We recommend{" "} - <a - href="https://openrouter.ai/keys" - target="_blank" - rel="noopener noreferrer" - className="text-primary underline underline-offset-4" - > - OpenRouter - </a>{" "} - to get started. + <div className="min-h-screen flex items-center justify-center px-4"> + <div className="w-full max-w-md space-y-6"> + <div className="space-y-2 text-center"> + <h2 className="text-xl font-semibold tracking-tight"> + Optional API keys + </h2> + <p className="text-sm text-muted-foreground"> + Add keys now or skip and configure them later in + Settings. </p> </div> - <div className="space-y-2 text-left"> - <Label htmlFor="openrouter-key">OpenRouter API Key</Label> - <Input - id="openrouter-key" - type="password" - placeholder="sk-or-..." - value={openRouterKey} - onChange={(e) => setOpenRouterKey(e.target.value)} - autoFocus - /> - <p className="text-xs text-muted-foreground"> - You can add more API keys later in Settings. - </p> + <div className="space-y-4"> + {ONBOARDING_API_KEY_FIELDS.map((field) => ( + <div key={field.provider} className="space-y-2"> + <Label htmlFor={`${field.provider}-api-key`}> + {field.label} + </Label> + <Input + id={`${field.provider}-api-key`} + placeholder={field.placeholder} + value={apiKeyInputs[field.provider]} + onChange={(event) => + handleApiKeyChange( + field.provider, + event.target.value, + ) + } + /> + </div> + ))} </div> <div className="flex flex-col gap-2"> @@ -128,9 +186,7 @@ export default function Onboarding({ onComplete }: { onComplete: () => void }) { onClick={() => void handleSaveAndComplete()} disabled={isSaving} > - {openRouterKey.trim() - ? "Save and continue" - : "Skip for now"}{" "} + {hasAnyApiKey ? "Save and continue" : "Skip for now"}{" "} <span className="text-sm">↵</span> </Button> </div> diff --git a/src/ui/components/PermissionsTab.tsx b/src/ui/components/PermissionsTab.tsx index 59b89515..09bf8a1f 100644 --- a/src/ui/components/PermissionsTab.tsx +++ b/src/ui/components/PermissionsTab.tsx @@ -6,7 +6,9 @@ import { Badge } from "@ui/components/ui/badge"; import { Trash2, DoorOpenIcon, BanIcon, CheckIcon } from "lucide-react"; import { getToolsetIcon } from "@core/chorus/Toolsets"; import * as ToolPermissionsAPI from "@core/chorus/api/ToolPermissionsAPI"; +import * as ToolYoloAPI from "@core/chorus/api/ToolYoloAPI"; import * as AppMetadataAPI from "@core/chorus/api/AppMetadataAPI"; +import { ToolsetsManager } from "@core/chorus/ToolsetsManager"; import { Separator } from "@ui/components/ui/separator"; import { Switch } from "@ui/components/ui/switch"; import { @@ -28,6 +30,33 @@ export const PermissionsTab: React.FC = () => { const { data: yoloMode } = AppMetadataAPI.useYoloMode(); const setYoloMode = AppMetadataAPI.useSetYoloMode(); + const allToolsDependency = ToolsetsManager.instance + .listToolsets() + .map( + (toolset) => + `${toolset.name}:${toolset + .listTools() + .map((tool) => tool.displayNameSuffix) + .join(",")}`, + ) + .join("|"); + const allTools = React.useMemo(() => { + // Keep allTools referentially stable until toolset/tool composition changes. + void allToolsDependency; + return ToolsetsManager.instance.listToolsets().flatMap((toolset) => + toolset.listTools().map((tool) => ({ + toolsetName: tool.toolsetName, + toolName: tool.displayNameSuffix, + })), + ); + }, [allToolsDependency]); + + const { data: toolYoloEntries } = ToolYoloAPI.useAllToolYolo( + yoloMode === false && allTools.length > 0, + ); + const setToolYolo = ToolYoloAPI.useSetToolYolo(); + const deleteToolYolo = ToolYoloAPI.useDeleteToolYolo(); + const groupedPermissions = React.useMemo(() => { if (!permissions) return {}; @@ -130,6 +159,74 @@ export const PermissionsTab: React.FC = () => { </Card> </div> + {yoloMode === false && allTools.length > 0 && ( + <div className="space-y-2"> + <div className="space-y-1"> + <h3 className="text-base font-semibold"> + Auto-accept specific tools + </h3> + <p className="text-sm text-muted-foreground"> + These tools will execute automatically without + prompting, even when Global YOLO is off. + </p> + </div> + <Card> + <CardContent className="p-4 space-y-3"> + {allTools.map(({ toolsetName, toolName }) => { + const isYolo = + toolYoloEntries?.some( + (e) => + e.toolsetName === toolsetName && + e.toolName === toolName, + ) ?? false; + const switchId = `tool-yolo-${toolsetName}-${toolName}`; + return ( + <div + key={`${toolsetName}-${toolName}`} + className="flex items-center justify-between" + > + <div className="flex items-center gap-2"> + {getToolsetIcon(toolsetName)} + <Label + htmlFor={switchId} + className="font-mono text-sm cursor-pointer" + > + {toolsetName}_{toolName} + </Label> + </div> + <Switch + id={switchId} + checked={isYolo} + onCheckedChange={(checked) => { + if (checked) { + setToolYolo.mutate({ + toolsetName, + toolName, + }); + } else { + deleteToolYolo.mutate({ + toolsetName, + toolName, + }); + } + }} + /> + </div> + ); + })} + </CardContent> + </Card> + </div> + )} + {yoloMode && allTools.length > 0 && ( + <div className="p-4 bg-muted rounded-lg"> + <p className="text-sm text-muted-foreground"> + Global YOLO is enabled — per-tool settings apply when + it's off. + </p> + </div> + )} + {yoloMode && Object.keys(groupedPermissions).length > 0 && ( <div className="mt-4 p-4 bg-muted rounded-lg flex items-center gap-2"> <p className="text-sm text-muted-foreground"> diff --git a/src/ui/components/ProjectView.tsx b/src/ui/components/ProjectView.tsx index 2c96e7ff..844b38d9 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,12 @@ export default function ProjectView() { const deleteProject = ProjectAPI.useDeleteProject(); const getOrCreateNewChat = ChatAPI.useGetOrCreateNewChat(); const setMagicProjectsEnabled = ProjectAPI.useSetMagicProjectsEnabled(); + const setProjectDefaultPromptProfile = + ProjectAPI.useSetProjectDefaultPromptProfile(); + const setProjectYoloMode = ProjectAPI.useSetProjectYoloMode(); + + // Queries + const { data: promptProfiles } = usePromptProfiles(); // Queries const projectsQuery = useQuery(ProjectAPI.projectQueries.list()); @@ -423,6 +439,81 @@ 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="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">YOLO Mode</h2> + <p className="text-xs text-muted-foreground font-[350] -mt-0.5"> + Override global YOLO setting for this project. + </p> + </div> + <Select + value={ + project.yoloMode === undefined + ? "inherit" + : project.yoloMode + ? "enabled" + : "disabled" + } + onValueChange={(v) => { + void setProjectYoloMode.mutateAsync({ + projectId, + yoloMode: + v === "inherit" + ? null + : v === "enabled", + }); + }} + > + <SelectTrigger className="w-36 h-7 text-xs shrink-0"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="inherit"> + Inherit Global + </SelectItem> + <SelectItem value="enabled">Enabled</SelectItem> + <SelectItem value="disabled"> + Disabled + </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.search.test.ts b/src/ui/components/VisibleModelsTab.search.test.ts new file mode 100644 index 00000000..4a3d0f61 --- /dev/null +++ b/src/ui/components/VisibleModelsTab.search.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { ModelConfig } from "@core/chorus/Models"; +import { + filterSubProvidersBySearch, + filterModelsBySearch, + parseSubProviderSearch, +} from "./visibleModelsSearch"; + +function makeModel( + id: string, + modelId: string, + displayName: string, +): ModelConfig { + return { + id, + modelId, + displayName, + } as ModelConfig; +} + +const MODELS: ModelConfig[] = [ + makeModel("1", "openrouter::openai/gpt-4o", "OpenAI GPT-4o"), + makeModel( + "2", + "openrouter::google/gemini-1.5-pro", + "Google Gemini 1.5 Pro", + ), + makeModel("3", "openrouter::meta-llama/llama-3.1-8b", "Llama 3.1 8B"), +]; + +const SUB_PROVIDERS = ["google", "meta-llama", "openai"]; + +describe("filterModelsBySearch", () => { + it("returns all models when search is empty", () => { + expect(filterModelsBySearch(MODELS, "", SUB_PROVIDERS)).toEqual(MODELS); + }); + + it("supports sub-provider prefix search with model terms", () => { + const filtered = filterModelsBySearch( + MODELS, + "openai: gpt-4", + SUB_PROVIDERS, + ); + expect(filtered).toEqual([MODELS[0]]); + }); + + it("supports provider-only prefix syntax with trailing colon", () => { + const filtered = filterModelsBySearch(MODELS, "google:", SUB_PROVIDERS); + expect(filtered).toEqual([MODELS[1]]); + }); + + it("parses provider prefix syntax for chip filtering", () => { + expect(parseSubProviderSearch("openai: gpt-4", SUB_PROVIDERS)).toEqual({ + matchedSubProvider: "openai", + remainingSearch: "gpt-4", + }); + }); + + it("keeps selected sub-providers visible while search narrows", () => { + const filtered = filterSubProvidersBySearch(SUB_PROVIDERS, "flash", [ + "google", + "openai", + ]); + expect(filtered).toEqual(["google", "openai"]); + }); + + it("applies plain text term filtering", () => { + const filtered = filterModelsBySearch(MODELS, "5.4", SUB_PROVIDERS); + expect(filtered).toEqual([]); + }); + + it("filters by multiple selected sub-providers", () => { + const filtered = filterModelsBySearch(MODELS, "", SUB_PROVIDERS, [ + "openai", + "google", + ]); + expect(filtered).toEqual([MODELS[0], MODELS[1]]); + }); +}); diff --git a/src/ui/components/VisibleModelsTab.tsx b/src/ui/components/VisibleModelsTab.tsx new file mode 100644 index 00000000..cb47f44d --- /dev/null +++ b/src/ui/components/VisibleModelsTab.tsx @@ -0,0 +1,413 @@ +import { useState, useMemo, useEffect } from "react"; +import { Button } from "./ui/button"; +import { Switch } from "./ui/switch"; +import { Input } from "./ui/input"; +import { + Collapsible, + CollapsibleTrigger, + CollapsibleContent, +} from "./ui/collapsible"; +import { + useProviderVisibleModels, + useSetModelVisibility, + useSetAllProviderModelsVisible, +} from "@core/chorus/api/ProviderVisibilityAPI"; +import { + useModelConfigs, + useRefreshOpenRouterModels, + useRefreshOllamaModels, + useRefreshLMStudioModels, +} from "@core/chorus/api/ModelsAPI"; +import { ModelConfig, ApiKeys, ProviderName } from "@core/chorus/Models"; +import { Loader2, RefreshCcw, ChevronDown, ChevronRight } from "lucide-react"; +import { getProviderName } from "@core/chorus/Models"; +import { useApiKeys } from "@core/chorus/api/AppMetadataAPI"; +import { canProceedWithProvider } from "@core/utilities/ProxyUtils"; +import { + filterSubProvidersBySearch, + filterModelsBySearch, + getSubProvider, +} from "./visibleModelsSearch"; + +const FETCHABLE_PROVIDERS = ["openrouter", "ollama", "lmstudio"] as const; +type FetchableProvider = (typeof FETCHABLE_PROVIDERS)[number]; + +const LOCAL_PROVIDERS = new Set(["ollama", "lmstudio"]); +const API_KEY_REQUIRED_PROVIDERS = new Set<ProviderName>([ + "openrouter", + "google", + "openai", + "anthropic", +]); +const SUB_PROVIDER_SEARCH_THRESHOLD = 10; + +const PROVIDER_LABELS: Record<string, string> = { + openrouter: "OpenRouter", + ollama: "Ollama", + lmstudio: "LM Studio", + anthropic: "Anthropic", + openai: "OpenAI", + google: "Google", + grok: "Grok", + perplexity: "Perplexity", +}; + +interface ProviderModelSectionProps { + provider: ProviderName; + providerModels: ModelConfig[]; + visibleModels: { modelId: string; isVisible: boolean }[] | undefined; + isFetchable: boolean; + isFetching: boolean; + onFetchModels: () => void; + onSetVisibility: (args: { + providerName: ProviderName; + modelId: string; + isVisible: boolean; + }) => void; + onSetAllVisibility: (args: { + providerName: ProviderName; + modelIds: string[]; + isVisible: boolean; + }) => void; + apiKeys: ApiKeys | undefined; +} + +function ProviderModelSection({ + provider, + providerModels, + visibleModels, + isFetchable, + isFetching, + onFetchModels, + onSetVisibility, + onSetAllVisibility, + apiKeys, +}: ProviderModelSectionProps) { + const isLocal = LOCAL_PROVIDERS.has(provider); + const providerHasKey = + isLocal || canProceedWithProvider(provider, apiKeys ?? {}).canProceed; + + const [isOpen, setIsOpen] = useState(isLocal || providerHasKey); + const [subProviderFilters, setSubProviderFilters] = useState<string[]>([]); + const [subProviderSearch, setSubProviderSearch] = useState(""); + + // Auto-expand when an API key is added for this provider + useEffect(() => { + if (providerHasKey && !isLocal) { + setIsOpen(true); + } + }, [providerHasKey, isLocal]); + + const subProviders = useMemo( + () => + Array.from( + new Set( + providerModels + .map((m) => getSubProvider(m.modelId)) + .filter((s): s is string => s !== null), + ), + ).sort(), + [providerModels], + ); + + const showSubProviderSearch = + subProviders.length > SUB_PROVIDER_SEARCH_THRESHOLD; + + const filteredSubProviders = useMemo(() => { + return filterSubProvidersBySearch( + subProviders, + subProviderSearch, + subProviderFilters, + ); + }, [subProviders, subProviderSearch, subProviderFilters]); + + const subProviderModelCounts = useMemo(() => { + const counts: Record<string, number> = {}; + for (const m of providerModels) { + const sub = getSubProvider(m.modelId); + if (sub) counts[sub] = (counts[sub] ?? 0) + 1; + } + return counts; + }, [providerModels]); + + const visibleProviderModels = useMemo(() => { + return filterModelsBySearch( + providerModels, + subProviderSearch, + subProviders, + subProviderFilters, + ); + }, [providerModels, subProviderFilters, subProviderSearch, subProviders]); + + const isAllVisible = visibleProviderModels.every((m) => { + const v = visibleModels?.find((vm) => vm.modelId === m.modelId); + return v ? v.isVisible : true; + }); + + const hasSubProviders = subProviders.length > 1; + + return ( + <Collapsible + open={isOpen} + onOpenChange={setIsOpen} + className="border rounded-lg" + > + <div className="p-4 flex items-center justify-between"> + <CollapsibleTrigger className="flex items-center gap-2 flex-1 text-left"> + {isOpen ? ( + <ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" /> + ) : ( + <ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" /> + )} + <h3 className="font-semibold"> + {PROVIDER_LABELS[provider] ?? provider} + </h3> + {!isLocal && !providerHasKey && ( + <span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full"> + No API key + </span> + )} + </CollapsibleTrigger> + <div className="flex items-center gap-2"> + {isFetchable && ( + <Button + variant="outline" + size="sm" + disabled={isFetching} + onClick={onFetchModels} + > + <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={() => + onSetAllVisibility({ + providerName: provider, + modelIds: visibleProviderModels.map( + (m) => m.modelId, + ), + isVisible: !isAllVisible, + }) + } + > + {isAllVisible ? "Hide All" : "Show All"} + </Button> + )} + </div> + </div> + + <CollapsibleContent className="px-4 pb-4 space-y-4"> + {/* Searchable sub-provider filter (only for large sub-provider lists) */} + {hasSubProviders && showSubProviderSearch && ( + <Input + placeholder="Search providers..." + value={subProviderSearch} + onChange={(e) => setSubProviderSearch(e.target.value)} + className="h-8 text-sm" + /> + )} + + {/* Sub-provider filter chips */} + {hasSubProviders && ( + <div className="flex flex-wrap gap-1.5"> + <button + onClick={() => setSubProviderFilters([])} + className={`px-2.5 py-0.5 rounded-full text-xs border transition-colors ${ + subProviderFilters.length === 0 + ? "bg-primary text-primary-foreground border-primary" + : "bg-background text-muted-foreground border-border hover:border-foreground/40" + }`} + > + All + </button> + {filteredSubProviders.map((sub) => ( + <button + key={sub} + onClick={() => + setSubProviderFilters((prev) => + prev.includes(sub) + ? prev.filter( + (item) => item !== sub, + ) + : [...prev, sub], + ) + } + className={`px-2.5 py-0.5 rounded-full text-xs border transition-colors ${ + subProviderFilters.includes(sub) + ? "bg-primary text-primary-foreground border-primary" + : "bg-background text-muted-foreground border-border hover:border-foreground/40" + }`} + > + {sub} + {showSubProviderSearch && + subProviderModelCounts[sub] !== + undefined && ( + <span className="ml-1 opacity-60"> + ({subProviderModelCounts[sub]}) + </span> + )} + </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> + ) : visibleProviderModels.length === 0 ? ( + <p className="text-sm text-muted-foreground"> + No models match your search. + </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) => + onSetVisibility({ + providerName: provider, + modelId: m.modelId, + isVisible: checked, + }) + } + /> + </div> + ); + })} + </div> + )} + </CollapsibleContent> + </Collapsible> + ); +} + +export function VisibleModelsTab() { + const { data: visibleModels, isLoading } = useProviderVisibleModels(); + const { data: allModels } = useModelConfigs(); + const { data: apiKeys } = useApiKeys(); + 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 }); + + 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: ProviderName[] = 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: ProviderName[] = [ + ...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> + + <div className="space-y-4"> + {orderedProviders.map((provider) => { + const providerModels = allModels.filter( + (m) => getProviderName(m.modelId) === provider, + ); + const hideModelsWithoutKey = + API_KEY_REQUIRED_PROVIDERS.has(provider) && + !canProceedWithProvider(provider, apiKeys ?? {}) + .canProceed; + const filteredProviderModels = hideModelsWithoutKey + ? [] + : providerModels; + const isFetchable = FETCHABLE_PROVIDERS.includes( + provider as FetchableProvider, + ); + const isFetching = + isFetchable && + fetchingProviders[provider as FetchableProvider]; + + return ( + <ProviderModelSection + key={provider} + provider={provider} + providerModels={filteredProviderModels} + visibleModels={visibleModels} + isFetchable={isFetchable} + isFetching={isFetching} + onFetchModels={() => + void handleFetchModels( + provider as FetchableProvider, + ) + } + onSetVisibility={setVisibility.mutate} + onSetAllVisibility={setAllVisibility.mutate} + apiKeys={apiKeys} + /> + ); + })} + </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/components/ui/textarea.tsx b/src/ui/components/ui/textarea.tsx index 29fe9dd8..e7c517ef 100644 --- a/src/ui/components/ui/textarea.tsx +++ b/src/ui/components/ui/textarea.tsx @@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( <textarea spellCheck={false} className={cn( - "flex min-h-[60px] rounded-sm bg-background px-3 py-2 placeholder:text-muted-foreground outline-none ring-1 ring-border focus:ring-accent-500 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50", + "flex w-full min-h-[60px] rounded-sm bg-background px-3 py-2 placeholder:text-muted-foreground outline-none ring-1 ring-border focus:ring-accent-500 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50", className, )} ref={ref} diff --git a/src/ui/components/visibleModelsSearch.ts b/src/ui/components/visibleModelsSearch.ts new file mode 100644 index 00000000..1c32751c --- /dev/null +++ b/src/ui/components/visibleModelsSearch.ts @@ -0,0 +1,135 @@ +import { ModelConfig } from "@core/chorus/Models"; + +/** + * 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. + */ +export 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 interface ParsedSubProviderSearch { + matchedSubProvider: string | null; + remainingSearch: string; +} + +export function parseSubProviderSearch( + search: string, + subProviders: string[], +): ParsedSubProviderSearch { + const trimmedSearch = search.trim(); + if (!trimmedSearch) { + return { + matchedSubProvider: null, + remainingSearch: "", + }; + } + + const normalizedSubProviders = new Set( + subProviders.map((subProvider) => subProvider.toLowerCase()), + ); + + const colonIdx = trimmedSearch.indexOf(":"); + if (colonIdx === -1) { + return { + matchedSubProvider: null, + remainingSearch: trimmedSearch, + }; + } + + const candidateSubProvider = trimmedSearch + .slice(0, colonIdx) + .trim() + .toLowerCase(); + if ( + !candidateSubProvider || + !normalizedSubProviders.has(candidateSubProvider) + ) { + return { + matchedSubProvider: null, + remainingSearch: trimmedSearch, + }; + } + + return { + matchedSubProvider: candidateSubProvider, + remainingSearch: trimmedSearch.slice(colonIdx + 1).trim(), + }; +} + +export function filterSubProvidersBySearch( + subProviders: string[], + search: string, + selectedSubProviders: string[] = [], +): string[] { + const { matchedSubProvider, remainingSearch } = parseSubProviderSearch( + search, + subProviders, + ); + const term = (matchedSubProvider ?? remainingSearch).toLowerCase(); + const visibleSubProviders = new Set( + (term + ? subProviders.filter((subProvider) => + subProvider.toLowerCase().includes(term), + ) + : subProviders + ).map((subProvider) => subProvider.toLowerCase()), + ); + + for (const selectedSubProvider of selectedSubProviders) { + visibleSubProviders.add(selectedSubProvider.toLowerCase()); + } + + return subProviders.filter((subProvider) => + visibleSubProviders.has(subProvider.toLowerCase()), + ); +} + +export function filterModelsBySearch( + models: ModelConfig[], + search: string, + subProviders: string[], + selectedSubProviders: string[] = [], +): ModelConfig[] { + const normalizedSelectedSubProviders = new Set( + selectedSubProviders.map((subProvider) => subProvider.toLowerCase()), + ); + const selectedFilteredModels = + normalizedSelectedSubProviders.size === 0 + ? models + : models.filter((model) => { + const subProvider = getSubProvider( + model.modelId, + )?.toLowerCase(); + return ( + subProvider !== undefined && + normalizedSelectedSubProviders.has(subProvider) + ); + }); + + const { matchedSubProvider, remainingSearch } = parseSubProviderSearch( + search, + subProviders, + ); + if (!matchedSubProvider && !remainingSearch) return selectedFilteredModels; + + const terms = remainingSearch.toLowerCase().split(/\s+/).filter(Boolean); + + return selectedFilteredModels.filter((model) => { + if (matchedSubProvider !== null) { + const subProvider = getSubProvider(model.modelId)?.toLowerCase(); + if (subProvider !== matchedSubProvider) return false; + } + + if (terms.length === 0) return true; + + const searchableText = + `${model.displayName} ${model.modelId}`.toLowerCase(); + return terms.every((term) => searchableText.includes(term)); + }); +} 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 -];