Compare commits
2 Commits
main
...
705608ae46
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
705608ae46 | ||
|
|
73fc42bf4f |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -107,10 +107,6 @@ jobs:
|
|||||||
# Merge reports after playwright-tests, even if some shards have failed
|
# Merge reports after playwright-tests, even if some shards have failed
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
needs: [test]
|
needs: [test]
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
actions: read
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
59
.github/workflows/playwright-comment.yml
vendored
59
.github/workflows/playwright-comment.yml
vendored
@@ -1,59 +0,0 @@
|
|||||||
name: Playwright Report Comment
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows: ["CI"]
|
|
||||||
types:
|
|
||||||
- completed
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
issues: write
|
|
||||||
actions: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
comment:
|
|
||||||
if: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.pull_requests[0].number }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.workflow_run.base_ref }}
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: lts/*
|
|
||||||
|
|
||||||
- name: Download Playwright HTML report
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: html-report--attempt-${{ github.event.workflow_run.run_attempt }}
|
|
||||||
path: playwright-report
|
|
||||||
github-token: ${{ github.token }}
|
|
||||||
repository: ${{ github.event.workflow_run.repository.full_name }}
|
|
||||||
run-id: ${{ github.event.workflow_run.id }}
|
|
||||||
if-no-artifact-found: warn
|
|
||||||
|
|
||||||
- name: Download blob reports
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: all-blob-reports
|
|
||||||
pattern: blob-report-*
|
|
||||||
merge-multiple: true
|
|
||||||
github-token: ${{ github.token }}
|
|
||||||
repository: ${{ github.event.workflow_run.repository.full_name }}
|
|
||||||
run-id: ${{ github.event.workflow_run.id }}
|
|
||||||
if-no-artifact-found: warn
|
|
||||||
|
|
||||||
- name: Generate Playwright summary comment
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
env:
|
|
||||||
PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
|
|
||||||
PLAYWRIGHT_RUN_ID: ${{ github.event.workflow_run.id }}
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const { run } = require('./scripts/generate-playwright-summary.js');
|
|
||||||
await run({ github, context, core });
|
|
||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
{ name: "windows", image: "windows-latest" },
|
{ name: "windows", image: "windows-latest" },
|
||||||
# See https://github.com/dyad-sh/dyad/issues/96
|
# See https://github.com/dyad-sh/dyad/issues/96
|
||||||
{ name: "linux", image: "ubuntu-22.04" },
|
{ name: "linux", image: "ubuntu-22.04" },
|
||||||
{ name: "macos-intel", image: "macos-15-intel" },
|
{ name: "macos-intel", image: "macos-13" },
|
||||||
{ name: "macos", image: "macos-latest" },
|
{ name: "macos", image: "macos-latest" },
|
||||||
]
|
]
|
||||||
runs-on: ${{ matrix.os.image }}
|
runs-on: ${{ matrix.os.image }}
|
||||||
|
|||||||
@@ -1,590 +0,0 @@
|
|||||||
# Dyad Custom Features Integration Guide
|
|
||||||
|
|
||||||
This guide explains how to use the custom features integration script to maintain your remove-limit modifications when updating the main Dyad codebase.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The integration script (`scripts/integrate-custom-features.sh`) helps you merge your custom remove-limit features with upstream updates from the original Dyad repository. It automatically:
|
|
||||||
|
|
||||||
- Creates backups before making changes
|
|
||||||
- Detects custom modifications in key files
|
|
||||||
- Creates missing custom files
|
|
||||||
- Validates the integration
|
|
||||||
- Provides restore functionality
|
|
||||||
|
|
||||||
## Features Integrated
|
|
||||||
|
|
||||||
### Remove Limit Features
|
|
||||||
- **Smart Context Management**: Advanced context handling with rolling summaries
|
|
||||||
- **Rate Limit Simulation**: Configurable rate limiting for testing
|
|
||||||
- **Enhanced Chat Streaming**: Improved message handling with custom payloads
|
|
||||||
- **Context Snippet Management**: Intelligent snippet organization and retrieval
|
|
||||||
|
|
||||||
### Smart Context System
|
|
||||||
- **Context Caching**: Efficient caching with TTL
|
|
||||||
- **Rolling Summaries**: Automatic summarization when context exceeds thresholds
|
|
||||||
- **Snippet Importance Scoring**: Intelligent ranking of context snippets
|
|
||||||
- **Size Management**: Automatic trimming and optimization
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Basic Integration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Integrate custom features (creates backup automatically)
|
|
||||||
./scripts/integrate-custom-features.sh integrate
|
|
||||||
|
|
||||||
# Validate current integration
|
|
||||||
./scripts/integrate-custom-features.sh validate
|
|
||||||
|
|
||||||
# Show help
|
|
||||||
./scripts/integrate-custom-features.sh help
|
|
||||||
```
|
|
||||||
|
|
||||||
### Restore from Backup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List available backups
|
|
||||||
ls -la backups/
|
|
||||||
|
|
||||||
# Restore from a specific backup
|
|
||||||
./scripts/integrate-custom-features.sh restore backup-20231201-120000
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files Managed
|
|
||||||
|
|
||||||
### Core Custom Files
|
|
||||||
- `src/components/HelpDialog.tsx` - Enhanced help dialog with custom features
|
|
||||||
- `src/components/chat/PromoMessage.tsx` - Modified promo messages
|
|
||||||
- `src/ipc/handlers/chat_stream_handlers.ts` - Enhanced chat streaming with remove-limit
|
|
||||||
- `src/ipc/ipc_client.ts` - Extended IPC client with smart context methods
|
|
||||||
- `src/ipc/ipc_host.ts` - Updated IPC host with new handlers
|
|
||||||
- `src/ipc/ipc_types.ts` - Extended type definitions
|
|
||||||
- `src/preload.ts` - Updated preload script with new channels
|
|
||||||
- `testing/fake-llm-server/chatCompletionHandler.ts` - Enhanced testing server
|
|
||||||
|
|
||||||
### New Custom Files
|
|
||||||
- `src/ipc/handlers/smart_context_handlers.ts` - Smart context IPC handlers
|
|
||||||
- `src/ipc/utils/smart_context_store.ts` - Context storage and management
|
|
||||||
- `src/hooks/useSmartContext.ts` - React hooks for smart context
|
|
||||||
|
|
||||||
## Complete Step-by-Step Integration Guide
|
|
||||||
|
|
||||||
This section provides detailed, copy-paste ready commands for every step of the integration process. Follow these commands exactly as written.
|
|
||||||
|
|
||||||
### 📋 Pre-Update Checklist (5 minutes)
|
|
||||||
|
|
||||||
**Step 1: Verify Current Working Directory**
|
|
||||||
```bash
|
|
||||||
# Make sure you're in the correct directory
|
|
||||||
pwd
|
|
||||||
# Expected output: /Users/kunthawatgreethong/Gitea/moreminimore-vibe
|
|
||||||
|
|
||||||
# If not, navigate to the correct directory
|
|
||||||
cd /Users/kunthawatgreethong/Gitea/moreminimore-vibe
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Check Git Status**
|
|
||||||
```bash
|
|
||||||
# Check if you have any uncommitted changes
|
|
||||||
git status
|
|
||||||
# Expected: "working tree clean" or list your changes
|
|
||||||
|
|
||||||
# If you have changes, commit them first
|
|
||||||
git add .
|
|
||||||
git commit -m "Save current state before upstream update"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 3: Verify Custom Features Are Working**
|
|
||||||
```bash
|
|
||||||
# Validate current integration
|
|
||||||
./scripts/integrate-custom-features.sh validate
|
|
||||||
# Expected: [SUCCESS] Integration validation passed
|
|
||||||
|
|
||||||
# If validation fails, fix issues before proceeding
|
|
||||||
./scripts/integrate-custom-features.sh help
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 4: Check Current Git Branch**
|
|
||||||
```bash
|
|
||||||
# Make sure you're on your main branch
|
|
||||||
git branch
|
|
||||||
# Expected: * main or * master (with * indicating current branch)
|
|
||||||
|
|
||||||
# If not on main branch, switch to it
|
|
||||||
git checkout main
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🔄 Upstream Update Process (10-15 minutes)
|
|
||||||
|
|
||||||
**Step 5: Fetch Latest Changes from Upstream**
|
|
||||||
```bash
|
|
||||||
# Fetch all changes from upstream repository
|
|
||||||
git fetch upstream
|
|
||||||
# Expected: No output (or shows what was fetched)
|
|
||||||
|
|
||||||
# Check what new commits are available
|
|
||||||
git log main..upstream/main --oneline
|
|
||||||
# Expected: List of new commits from upstream
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 6: Stash Your Local Changes (Optional but Recommended)**
|
|
||||||
```bash
|
|
||||||
# If you have any local changes not committed
|
|
||||||
git stash push -m "Temporary stash before upstream merge"
|
|
||||||
# Expected: Saved working directory and index state
|
|
||||||
|
|
||||||
# To see your stashes
|
|
||||||
git stash list
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 7: Merge Upstream Changes**
|
|
||||||
```bash
|
|
||||||
# Merge upstream changes into your main branch
|
|
||||||
git merge upstream/main
|
|
||||||
# Expected: Shows merge summary or conflicts
|
|
||||||
|
|
||||||
# If there are conflicts, you'll see:
|
|
||||||
# CONFLICT (content): Merge conflict in file-name
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 8: Handle Merge Conflicts (If Any)**
|
|
||||||
```bash
|
|
||||||
# If conflicts exist, check which files have conflicts
|
|
||||||
git status
|
|
||||||
# Look for files marked as "both modified"
|
|
||||||
|
|
||||||
# Open conflicted files and resolve them manually
|
|
||||||
# Look for these markers in files:
|
|
||||||
# <<<<<<< HEAD
|
|
||||||
# Your changes
|
|
||||||
# =======
|
|
||||||
# Upstream changes
|
|
||||||
# >>>>>>> upstream/main
|
|
||||||
|
|
||||||
# After resolving conflicts in each file:
|
|
||||||
git add path/to/resolved/file.ts
|
|
||||||
|
|
||||||
# Continue with merge when all conflicts resolved
|
|
||||||
git merge --continue
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 9: Restore Stashed Changes (If You Stashed)**
|
|
||||||
```bash
|
|
||||||
# Restore your stashed changes
|
|
||||||
git stash pop
|
|
||||||
# Expected: Shows what was restored
|
|
||||||
|
|
||||||
# If there are new conflicts, resolve them
|
|
||||||
git status
|
|
||||||
git add . (resolve conflicts first)
|
|
||||||
git commit -m "Merge upstream changes and resolve conflicts"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🔧 Integration Process (5-10 minutes)
|
|
||||||
|
|
||||||
**Step 10: Run the Integration Script**
|
|
||||||
```bash
|
|
||||||
# Execute the integration script
|
|
||||||
./scripts/integrate-custom-features.sh integrate
|
|
||||||
# Expected output:
|
|
||||||
# [2025-12-18 HH:MM:SS] Starting custom features integration...
|
|
||||||
# [2025-12-18 HH:MM:SS] Creating backup: backup-YYYYMMDD-HHMMSS
|
|
||||||
# [SUCCESS] Backup created at: /Users/kunthawatgreethong/Gitea/moreminimore-vibe/backups/backup-YYYYMMDD-HHMMSS
|
|
||||||
# [2025-12-18 HH:MM:SS] Creating missing custom files...
|
|
||||||
# [SUCCESS] Missing custom files created
|
|
||||||
# [2025-12-18 HH:MM:SS] Validating integration...
|
|
||||||
# [SUCCESS] Integration validation passed
|
|
||||||
# [SUCCESS] Custom features integration completed successfully!
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 11: Verify Integration Success**
|
|
||||||
```bash
|
|
||||||
# Check that all expected files exist
|
|
||||||
ls -la src/ipc/handlers/smart_context_handlers.ts
|
|
||||||
ls -la src/ipc/utils/smart_context_store.ts
|
|
||||||
ls -la src/hooks/useSmartContext.ts
|
|
||||||
|
|
||||||
# Expected: All three files should exist and show file info
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ Validation and Testing (10-15 minutes)
|
|
||||||
|
|
||||||
**Step 12: Run Validation Script**
|
|
||||||
```bash
|
|
||||||
# Validate the integration
|
|
||||||
./scripts/integrate-custom-features.sh validate
|
|
||||||
# Expected:
|
|
||||||
# [2025-12-18 HH:MM:SS] Validating integration...
|
|
||||||
# [SUCCESS] Integration validation passed
|
|
||||||
|
|
||||||
# If you see warnings, note them but continue
|
|
||||||
# Warnings about missing custom modifications are usually OK
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 13: Check TypeScript Compilation**
|
|
||||||
```bash
|
|
||||||
# Check for TypeScript errors (may have some MCP-related warnings)
|
|
||||||
npm run ts
|
|
||||||
# Expected: May show some MCP-related errors but should complete
|
|
||||||
# If many new errors appear, investigate before proceeding
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 14: Test Application Startup**
|
|
||||||
```bash
|
|
||||||
# Install any new dependencies if needed
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Start the application in development mode
|
|
||||||
npm start
|
|
||||||
# Expected: Application should start without critical errors
|
|
||||||
# Let it run for 30-60 seconds to ensure stability
|
|
||||||
# Press Ctrl+C to stop when confirmed working
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 15: Test Custom Features Manually**
|
|
||||||
```bash
|
|
||||||
# Start the application again
|
|
||||||
npm start
|
|
||||||
|
|
||||||
# Test these features in the UI:
|
|
||||||
# 1. Create a new chat
|
|
||||||
# 2. Send a message
|
|
||||||
# 3. Check if smart context is working (no obvious errors)
|
|
||||||
# 4. Verify help dialog opens correctly
|
|
||||||
# 5. Check that promo messages display properly
|
|
||||||
|
|
||||||
# If everything works, integration is successful!
|
|
||||||
# If issues occur, proceed to troubleshooting section
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🚨 If Something Goes Wrong
|
|
||||||
|
|
||||||
**Step 16: Identify the Problem**
|
|
||||||
```bash
|
|
||||||
# Check what went wrong
|
|
||||||
./scripts/integrate-custom-features.sh validate
|
|
||||||
# Note any errors or warnings
|
|
||||||
|
|
||||||
# Check application logs if it won't start
|
|
||||||
npm start 2>&1 | tee startup-errors.log
|
|
||||||
# Review startup-errors.log for clues
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 17: Restore from Backup (If Needed)**
|
|
||||||
```bash
|
|
||||||
# List available backups
|
|
||||||
ls -la backups/
|
|
||||||
# Expected: List of backup directories with timestamps
|
|
||||||
|
|
||||||
# Find your most recent backup (look for the latest timestamp)
|
|
||||||
# Example: backup-20231218-154512
|
|
||||||
|
|
||||||
# Restore from the most recent working backup
|
|
||||||
./scripts/integrate-custom-features.sh restore backup-20231218-154512
|
|
||||||
# Expected:
|
|
||||||
# [2025-12-18 HH:MM:SS] Restoring from backup: backup-20231218-154512
|
|
||||||
# [SUCCESS] Restore completed
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 18: Verify Restoration**
|
|
||||||
```bash
|
|
||||||
# Check that custom features are back
|
|
||||||
./scripts/integrate-custom-features.sh validate
|
|
||||||
# Expected: [SUCCESS] Integration validation passed
|
|
||||||
|
|
||||||
# Test application startup
|
|
||||||
npm start
|
|
||||||
# Should work as it did before the update
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 19: Manual Investigation (If Restoration Doesn't Work)**
|
|
||||||
```bash
|
|
||||||
# Check Git state
|
|
||||||
git status
|
|
||||||
git log --oneline -10
|
|
||||||
|
|
||||||
# Check specific files for issues
|
|
||||||
git diff HEAD~1 src/ipc/ipc_types.ts
|
|
||||||
git diff HEAD~1 src/ipc/ipc_client.ts
|
|
||||||
|
|
||||||
# Look for specific error patterns
|
|
||||||
grep -r "SmartContext" src/ --include="*.ts" --include="*.tsx"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🔍 Troubleshooting Commands
|
|
||||||
|
|
||||||
**Check Integration Script Health**
|
|
||||||
```bash
|
|
||||||
# Make script executable (if permission issues)
|
|
||||||
chmod +x scripts/integrate-custom-features.sh
|
|
||||||
|
|
||||||
# Test script help
|
|
||||||
./scripts/integrate-custom-features.sh help
|
|
||||||
# Should show usage instructions
|
|
||||||
```
|
|
||||||
|
|
||||||
**Verify File Permissions**
|
|
||||||
```bash
|
|
||||||
# Check script permissions
|
|
||||||
ls -la scripts/integrate-custom-features.sh
|
|
||||||
# Should show -rwxr-xr-x (executable)
|
|
||||||
|
|
||||||
# Fix permissions if needed
|
|
||||||
chmod 755 scripts/integrate-custom-features.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
**Check Node.js and npm**
|
|
||||||
```bash
|
|
||||||
# Verify Node.js version
|
|
||||||
node --version
|
|
||||||
# Expected: v18.x.x or higher
|
|
||||||
|
|
||||||
# Verify npm version
|
|
||||||
npm --version
|
|
||||||
# Expected: 9.x.x or higher
|
|
||||||
|
|
||||||
# Clear npm cache if needed
|
|
||||||
npm cache clean --force
|
|
||||||
```
|
|
||||||
|
|
||||||
**Check Git Configuration**
|
|
||||||
```bash
|
|
||||||
# Verify upstream remote is configured
|
|
||||||
git remote -v
|
|
||||||
# Should show upstream remote pointing to dyad-sh/dyad
|
|
||||||
|
|
||||||
# If upstream is missing, add it
|
|
||||||
git remote add upstream https://github.com/dyad-sh/dyad.git
|
|
||||||
```
|
|
||||||
|
|
||||||
### 📝 Quick Reference Commands
|
|
||||||
|
|
||||||
**Emergency Restore (One-liner)**
|
|
||||||
```bash
|
|
||||||
# Restore latest backup (replace with actual backup name)
|
|
||||||
./scripts/integrate-custom-features.sh restore $(ls -t backups/ | head -1)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Check All Backups**
|
|
||||||
```bash
|
|
||||||
# List all backups with details
|
|
||||||
ls -la backups/ | grep backup
|
|
||||||
```
|
|
||||||
|
|
||||||
**Force Clean Integration**
|
|
||||||
```bash
|
|
||||||
# For problematic integrations, start fresh
|
|
||||||
git clean -fd
|
|
||||||
git reset --hard HEAD
|
|
||||||
./scripts/integrate-custom-features.sh integrate
|
|
||||||
```
|
|
||||||
|
|
||||||
**Verify All Custom Files**
|
|
||||||
```bash
|
|
||||||
# Quick check that all custom files exist
|
|
||||||
files=("src/ipc/handlers/smart_context_handlers.ts" "src/ipc/utils/smart_context_store.ts" "src/hooks/useSmartContext.ts")
|
|
||||||
for file in "${files[@]}"; do
|
|
||||||
if [[ -f "$file" ]]; then
|
|
||||||
echo "✅ $file exists"
|
|
||||||
else
|
|
||||||
echo "❌ $file missing"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
```
|
|
||||||
|
|
||||||
### ⏱️ Time Estimates
|
|
||||||
|
|
||||||
- **Pre-Update Checklist**: 5 minutes
|
|
||||||
- **Upstream Update**: 10-15 minutes (longer if many conflicts)
|
|
||||||
- **Integration Process**: 5-10 minutes
|
|
||||||
- **Validation and Testing**: 10-15 minutes
|
|
||||||
- **Troubleshooting**: 15-30 minutes (if needed)
|
|
||||||
|
|
||||||
**Total Time**: 30-60 minutes for smooth integration, up to 2 hours if problems occur.
|
|
||||||
|
|
||||||
### 🎯 Success Indicators
|
|
||||||
|
|
||||||
You'll know the integration was successful when:
|
|
||||||
|
|
||||||
1. ✅ Integration script runs without errors
|
|
||||||
2. ✅ Validation script shows "[SUCCESS] Integration validation passed"
|
|
||||||
3. ✅ Application starts with `npm start`
|
|
||||||
4. ✅ No critical errors in console
|
|
||||||
5. ✅ Custom features work in the UI
|
|
||||||
6. ✅ TypeScript compilation completes (may have warnings)
|
|
||||||
|
|
||||||
If all these are true, your custom features have been successfully integrated with the latest upstream changes!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Integration Workflow Summary
|
|
||||||
|
|
||||||
### Before Upstream Update
|
|
||||||
1. **Current State**: Your custom features are integrated
|
|
||||||
2. **Backup**: Script automatically creates backup
|
|
||||||
3. **Validation**: Ensure everything is working
|
|
||||||
|
|
||||||
### After Upstream Update
|
|
||||||
1. **Update Code**: Pull latest changes from upstream
|
|
||||||
2. **Run Integration**: `./scripts/integrate-custom-features.sh integrate`
|
|
||||||
3. **Validate**: Check that integration succeeded
|
|
||||||
4. **Test**: Verify custom features work correctly
|
|
||||||
|
|
||||||
### If Something Goes Wrong
|
|
||||||
1. **Restore**: `./scripts/integrate-custom-features.sh restore <backup-name>`
|
|
||||||
2. **Investigate**: Check what conflicts occurred
|
|
||||||
3. **Manual Merge**: Resolve conflicts manually if needed
|
|
||||||
4. **Retry**: Run integration again
|
|
||||||
|
|
||||||
## Custom Features Explained
|
|
||||||
|
|
||||||
### Smart Context System
|
|
||||||
|
|
||||||
The smart context system provides intelligent management of chat context to handle longer conversations without hitting token limits.
|
|
||||||
|
|
||||||
#### Key Components
|
|
||||||
|
|
||||||
1. **SmartContextStore**: Manages context caching, summarization, and retrieval
|
|
||||||
2. **Context Snippets**: Individual pieces of context with importance scoring
|
|
||||||
3. **Rolling Summaries**: Automatically generated summaries of older messages
|
|
||||||
4. **Size Management**: Intelligent trimming based on importance and recency
|
|
||||||
|
|
||||||
#### Usage in Components
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useSmartContext, useUpdateSmartContext } from '@/hooks/useSmartContext';
|
|
||||||
|
|
||||||
// Get smart context for a chat
|
|
||||||
const { data: context, isLoading } = useSmartContext({
|
|
||||||
chatId: 123,
|
|
||||||
maxTokens: 8000,
|
|
||||||
includeSummaries: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update context with new content
|
|
||||||
const updateContext = useUpdateSmartContext();
|
|
||||||
updateContext.mutate({
|
|
||||||
chatId: 123,
|
|
||||||
content: "New message content",
|
|
||||||
type: "message",
|
|
||||||
importance: 1.0
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rate Limit Simulation
|
|
||||||
|
|
||||||
The system includes configurable rate limiting for testing purposes:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In chat_stream_handlers.ts
|
|
||||||
const rateLimitConfig = {
|
|
||||||
enabled: true,
|
|
||||||
requestsPerMinute: 60,
|
|
||||||
burstLimit: 10
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Enhanced Chat Streaming
|
|
||||||
|
|
||||||
Improved message handling with:
|
|
||||||
- Custom payload processing
|
|
||||||
- Better error handling
|
|
||||||
- Enhanced metadata support
|
|
||||||
- Streaming optimizations
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Smart Context Settings
|
|
||||||
|
|
||||||
You can configure the smart context behavior by modifying the `SmartContextStore`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In src/ipc/utils/smart_context_store.ts
|
|
||||||
private maxContextSize = 100000; // 100k characters
|
|
||||||
private maxSnippets = 50;
|
|
||||||
private summaryThreshold = 20000; // Summarize when context exceeds this
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rate Limit Settings
|
|
||||||
|
|
||||||
Configure rate limiting in the chat stream handlers:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Adjust these values based on your needs
|
|
||||||
const RATE_LIMIT_CONFIG = {
|
|
||||||
requestsPerMinute: 60,
|
|
||||||
burstLimit: 10,
|
|
||||||
enabled: process.env.NODE_ENV === 'development'
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
1. **TypeScript Errors**: The script skips TypeScript compilation due to existing MCP issues. Focus on functionality first.
|
|
||||||
2. **Missing Custom Modifications**: The script warns if files don't contain expected custom patterns.
|
|
||||||
3. **Backup Restoration**: Always restore from the most recent working backup.
|
|
||||||
|
|
||||||
### Validation Warnings
|
|
||||||
|
|
||||||
If you see warnings about missing custom modifications:
|
|
||||||
|
|
||||||
1. Check if the file actually needs custom changes
|
|
||||||
2. Verify the custom patterns are being detected correctly
|
|
||||||
3. Manually add custom modifications if needed
|
|
||||||
|
|
||||||
### Manual Intervention
|
|
||||||
|
|
||||||
Sometimes you may need to manually merge changes:
|
|
||||||
|
|
||||||
1. **Conflicts**: Resolve Git conflicts in key files
|
|
||||||
2. **New Features**: Add custom features to new upstream files
|
|
||||||
3. **API Changes**: Update custom code to match new APIs
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Regular Backups**: Always create backups before major changes
|
|
||||||
2. **Test Thoroughly**: Verify all custom features work after integration
|
|
||||||
3. **Document Changes**: Keep notes about manual modifications
|
|
||||||
4. **Version Control**: Commit working states regularly
|
|
||||||
5. **Gradual Updates**: Update upstream changes incrementally
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Adding New Custom Features
|
|
||||||
|
|
||||||
1. **Update Script**: Add new files to the `CUSTOM_FILES` or `NEW_CUSTOM_FILES` arrays
|
|
||||||
2. **Create Handlers**: Add IPC handlers for new functionality
|
|
||||||
3. **Update Types**: Extend type definitions as needed
|
|
||||||
4. **Test Integration**: Ensure new features integrate properly
|
|
||||||
|
|
||||||
### Modifying Existing Features
|
|
||||||
|
|
||||||
1. **Update Patterns**: Add new detection patterns to `has_custom_modifications()`
|
|
||||||
2. **Test Validation**: Ensure the script detects your changes
|
|
||||||
3. **Document Updates**: Update this documentation
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
If you encounter issues:
|
|
||||||
|
|
||||||
1. **Check Logs**: Look for error messages in the script output
|
|
||||||
2. **Restore Backup**: Use a recent backup to restore working state
|
|
||||||
3. **Manual Merge**: Resolve conflicts manually if needed
|
|
||||||
4. **Test Incrementally**: Test changes one at a time
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
Planned improvements to the integration script:
|
|
||||||
|
|
||||||
- **Automatic Conflict Resolution**: Smarter merging of conflicting changes
|
|
||||||
- **Enhanced Validation**: More comprehensive testing
|
|
||||||
- **GUI Interface**: Visual tool for managing integrations
|
|
||||||
- **Cloud Backups**: Optional cloud backup storage
|
|
||||||
- **Rollback System**: More granular rollback capabilities
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Note**: This integration script is designed to work with the specific remove-limit features. If you have additional custom modifications, you may need to extend the script accordingly.
|
|
||||||
198
UPDATE_GUIDE.md
Normal file
198
UPDATE_GUIDE.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# Dyad Update Management Guide
|
||||||
|
|
||||||
|
This guide explains how to update your forked Dyad application while preserving your custom modifications.
|
||||||
|
|
||||||
|
## 🎯 Overview
|
||||||
|
|
||||||
|
Your setup uses a **selective update strategy** that:
|
||||||
|
- Keeps your custom code separate from the main codebase
|
||||||
|
- Automatically preserves custom modifications during updates
|
||||||
|
- Provides backup and rollback capabilities
|
||||||
|
- Minimizes merge conflicts
|
||||||
|
|
||||||
|
## 📁 Custom Code Structure
|
||||||
|
|
||||||
|
Your custom modifications are organized in `src/custom/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/custom/
|
||||||
|
├── index.ts # Main entry point for custom features
|
||||||
|
├── hooks/
|
||||||
|
│ └── useSmartContext.ts # Custom smart context hook
|
||||||
|
├── ipc/
|
||||||
|
│ └── smart_context_handlers.ts # Custom IPC handlers
|
||||||
|
└── utils/
|
||||||
|
└── smart_context_store.ts # Custom utilities
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Update Process
|
||||||
|
|
||||||
|
### Method 1: Automated Update (Recommended)
|
||||||
|
|
||||||
|
Use the provided update script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./update-dyad-v2.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**What the script does:**
|
||||||
|
1. Creates a timestamped backup
|
||||||
|
2. Backs up your custom code
|
||||||
|
3. Fetches latest changes from upstream
|
||||||
|
4. Resets to the latest upstream version
|
||||||
|
5. Restores your custom code
|
||||||
|
6. Pushes updates to your fork
|
||||||
|
|
||||||
|
### Method 2: Manual Update
|
||||||
|
|
||||||
|
If you prefer manual control:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create backup
|
||||||
|
cp -r src/custom/ dyad-backup-$(date +%Y%m%d-%H%M%S)/
|
||||||
|
|
||||||
|
# 2. Fetch latest changes
|
||||||
|
git fetch upstream
|
||||||
|
|
||||||
|
# 3. Reset to latest upstream
|
||||||
|
git reset --hard upstream/main
|
||||||
|
|
||||||
|
# 4. Restore custom code
|
||||||
|
cp -r dyad-backup-*/src/custom/ src/
|
||||||
|
|
||||||
|
# 5. Commit and push
|
||||||
|
git add src/custom/
|
||||||
|
git commit -m "Restore custom code after update"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Update Workflow
|
||||||
|
|
||||||
|
### Before Updating
|
||||||
|
1. **Test current state** - Ensure your app works properly
|
||||||
|
2. **Commit any changes** - Don't have uncommitted work
|
||||||
|
3. **Check custom code** - Note any modifications that might need updates
|
||||||
|
|
||||||
|
### After Updating
|
||||||
|
1. **Run npm install** - Update dependencies if needed
|
||||||
|
2. **Test the application** - Ensure everything works
|
||||||
|
3. **Check custom integrations** - Verify custom features still work
|
||||||
|
4. **Update custom code** if needed - Adapt to any API changes
|
||||||
|
|
||||||
|
## 🛠️ Adding New Custom Features
|
||||||
|
|
||||||
|
When adding new custom features:
|
||||||
|
|
||||||
|
1. **Place in src/custom/** - Keep custom code organized
|
||||||
|
2. **Update imports** - Use relative imports within custom directory
|
||||||
|
3. **Document changes** - Note what each custom feature does
|
||||||
|
4. **Test integration** - Ensure custom features work with main app
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```typescript
|
||||||
|
// src/custom/components/MyCustomComponent.tsx
|
||||||
|
import { useSmartContext } from '../hooks/useSmartContext';
|
||||||
|
|
||||||
|
export const MyCustomComponent = () => {
|
||||||
|
// Your custom logic
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 Troubleshooting
|
||||||
|
|
||||||
|
### Merge Conflicts
|
||||||
|
If you encounter merge conflicts during manual updates:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Abort the merge
|
||||||
|
git merge --abort
|
||||||
|
|
||||||
|
# Use the automated script instead
|
||||||
|
./update-dyad-v2.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Code Not Working
|
||||||
|
After an update, if custom features don't work:
|
||||||
|
|
||||||
|
1. **Check API changes** - The upstream may have changed interfaces
|
||||||
|
2. **Update imports** - File paths might have changed
|
||||||
|
3. **Review breaking changes** - Check upstream release notes
|
||||||
|
4. **Test incrementally** - Isolate the problematic code
|
||||||
|
|
||||||
|
### Backup Restoration
|
||||||
|
If you need to restore from backup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find your backup directory
|
||||||
|
ls dyad-backup-*
|
||||||
|
|
||||||
|
# Restore specific files
|
||||||
|
cp -r dyad-backup-YYYYMMDD-HHMMSS/src/custom/ src/
|
||||||
|
|
||||||
|
# Or restore entire project (last resort)
|
||||||
|
rm -rf * .gitignore
|
||||||
|
cp -r dyad-backup-YYYYMMDD-HHMMSS/* .
|
||||||
|
cp dyad-backup-YYYYMMDD-HHMMSS/.gitignore .
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Best Practices
|
||||||
|
|
||||||
|
### Regular Maintenance
|
||||||
|
- **Update frequently** - Smaller updates are easier to manage
|
||||||
|
- **Test after each update** - Catch issues early
|
||||||
|
- **Keep custom code minimal** - Only customize what's necessary
|
||||||
|
- **Document customizations** - Future you will thank you
|
||||||
|
|
||||||
|
### Code Organization
|
||||||
|
- **Separate concerns** - Keep UI, logic, and utilities separate
|
||||||
|
- **Use TypeScript** - Catch integration issues early
|
||||||
|
- **Follow existing patterns** - Match the upstream code style
|
||||||
|
- **Avoid modifying core files** - Use extension patterns when possible
|
||||||
|
|
||||||
|
### Backup Strategy
|
||||||
|
- **Multiple backups** - Keep several backup versions
|
||||||
|
- **Offsite backup** - Consider cloud storage for critical backups
|
||||||
|
- **Test backups** - Ensure you can restore from backup
|
||||||
|
- **Label clearly** - Use descriptive backup names
|
||||||
|
|
||||||
|
## 🔧 Advanced Configuration
|
||||||
|
|
||||||
|
### Custom Update Script
|
||||||
|
You can modify `update-dyad-v2.sh` to:
|
||||||
|
- Skip certain files from backup
|
||||||
|
- Add custom post-update steps
|
||||||
|
- Include additional validation
|
||||||
|
- Send notifications on completion
|
||||||
|
|
||||||
|
### Selective File Restoration
|
||||||
|
To restore only specific custom files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Restore specific directory
|
||||||
|
cp -r dyad-backup-*/src/custom/hooks/ src/custom/
|
||||||
|
|
||||||
|
# Restore specific file
|
||||||
|
cp dyad-backup-*/src/custom/index.ts src/custom/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📞 Getting Help
|
||||||
|
|
||||||
|
If you encounter issues:
|
||||||
|
|
||||||
|
1. **Check this guide first** - Most common issues are covered
|
||||||
|
2. **Review the script output** - Error messages are informative
|
||||||
|
3. **Test with a clean state** - Start fresh if needed
|
||||||
|
4. **Document the issue** - Note what you were trying to do
|
||||||
|
|
||||||
|
## 🎉 Success Indicators
|
||||||
|
|
||||||
|
You'll know the update was successful when:
|
||||||
|
- ✅ Script completes without errors
|
||||||
|
- ✅ Custom code is present in `src/custom/`
|
||||||
|
- ✅ Application starts and runs normally
|
||||||
|
- ✅ Custom features work as expected
|
||||||
|
- ✅ No merge conflicts in git status
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Remember**: The goal is to make updates painless and predictable. When in doubt, use the automated script and keep good backups!
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
99b0cdf feat: implement custom smart context functionality with hooks, IPC handlers, and utilities
|
|
||||||
7cf8317 Fix Playwright report comments on forked PRs (#1975)
|
|
||||||
2e31c50 Fixing scrollbar flickering in annotator mode (#1968)
|
|
||||||
3fd45ec Do not hardcode 32100 port (#1969)
|
|
||||||
47992f4 Leave GitHub comment with playwright results (#1965)
|
|
||||||
91cf1e9 Support shared modules for supabase edge functions (#1964)
|
|
||||||
a6d6a4c Rename Agent mode to Build with MCP in UI (#1966)
|
|
||||||
213def4 Use user info proxy (#1963)
|
|
||||||
9d33f37 logging and presenting cpu/memory usage when app is force-closed (#1894)
|
|
||||||
a4ab1a7 Annotator (#1861)
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
On branch main
|
|
||||||
Your branch is up to date with 'origin/main'.
|
|
||||||
|
|
||||||
Changes not staged for commit:
|
|
||||||
(use "git add/rm <file>..." to update what will be committed)
|
|
||||||
(use "git restore <file>..." to discard changes in working directory)
|
|
||||||
deleted: dyad-backup-20251218-085122/commit-history.txt
|
|
||||||
deleted: dyad-backup-20251218-085122/custom/hooks/useSmartContext.ts
|
|
||||||
deleted: dyad-backup-20251218-085122/custom/index.ts
|
|
||||||
deleted: dyad-backup-20251218-085122/custom/ipc/smart_context_handlers.ts
|
|
||||||
deleted: dyad-backup-20251218-085122/custom/utils/smart_context_store.ts
|
|
||||||
deleted: dyad-backup-20251218-085122/last-changes.diff
|
|
||||||
|
|
||||||
Untracked files:
|
|
||||||
(use "git add <file>..." to include in what will be committed)
|
|
||||||
backups/
|
|
||||||
dyad-remove-limit-doc/
|
|
||||||
scripts/integrate-custom-features.sh
|
|
||||||
|
|
||||||
no changes added to commit (use "git add" and/or "git commit -a")
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "dyad",
|
|
||||||
"productName": "dyad",
|
|
||||||
"version": "0.30.0-beta.1",
|
|
||||||
"description": "Free, local, open-source AI app builder",
|
|
||||||
"main": ".vite/build/main.js",
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/dyad-sh/dyad.git"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"clean": "rimraf out scaffold/node_modules",
|
|
||||||
"start": "electron-forge start",
|
|
||||||
"dev:engine": "cross-env DYAD_ENGINE_URL=http://localhost:8080/v1 npm start",
|
|
||||||
"staging:engine": "cross-env DYAD_ENGINE_URL=https://staging---dyad-llm-engine-kq7pivehnq-uc.a.run.app/v1 npm start",
|
|
||||||
"package": "npm run clean && electron-forge package",
|
|
||||||
"make": "npm run clean && electron-forge make",
|
|
||||||
"publish": "npm run clean && electron-forge publish",
|
|
||||||
"verify-release": "node scripts/verify-release-assets.js",
|
|
||||||
"ts": "npm run ts:main && npm run ts:workers",
|
|
||||||
"ts:main": "npx tsc -p tsconfig.app.json --noEmit",
|
|
||||||
"ts:workers": "npx tsc -p workers/tsc/tsconfig.json --noEmit",
|
|
||||||
"lint": "npx oxlint --fix",
|
|
||||||
"lint:fix": "npx oxlint --fix --fix-suggestions --fix-dangerously",
|
|
||||||
"db:generate": "drizzle-kit generate",
|
|
||||||
"db:push": "drizzle-kit push",
|
|
||||||
"db:studio": "drizzle-kit studio",
|
|
||||||
"prettier:check": "npx prettier --check .",
|
|
||||||
"prettier": "npx prettier --write .",
|
|
||||||
"presubmit": "npm run prettier:check && npm run lint",
|
|
||||||
"test": "vitest run",
|
|
||||||
"test:watch": "vitest",
|
|
||||||
"test:ui": "vitest --ui",
|
|
||||||
"extract-codebase": "ts-node scripts/extract-codebase.ts",
|
|
||||||
"init-precommit": "husky",
|
|
||||||
"pre:e2e": "cross-env E2E_TEST_BUILD=true npm run package",
|
|
||||||
"e2e": "playwright test",
|
|
||||||
"e2e:shard": "playwright test --shard"
|
|
||||||
},
|
|
||||||
"keywords": [],
|
|
||||||
"author": {
|
|
||||||
"name": "Will Chen",
|
|
||||||
"email": "willchen90@gmail.com"
|
|
||||||
},
|
|
||||||
"license": "MIT",
|
|
||||||
"devDependencies": {
|
|
||||||
"@electron-forge/cli": "^7.8.0",
|
|
||||||
"@electron-forge/maker-deb": "^7.8.0",
|
|
||||||
"@electron-forge/maker-rpm": "^7.8.0",
|
|
||||||
"@electron-forge/maker-squirrel": "^7.8.0",
|
|
||||||
"@electron-forge/maker-zip": "^7.8.0",
|
|
||||||
"@electron-forge/plugin-auto-unpack-natives": "^7.8.0",
|
|
||||||
"@electron-forge/plugin-fuses": "^7.8.0",
|
|
||||||
"@electron-forge/plugin-vite": "^7.8.0",
|
|
||||||
"@electron-forge/publisher-github": "^7.8.0",
|
|
||||||
"@electron/fuses": "^1.8.0",
|
|
||||||
"@playwright/test": "^1.52.0",
|
|
||||||
"@testing-library/react": "^16.3.0",
|
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
|
||||||
"@types/fs-extra": "^11.0.4",
|
|
||||||
"@types/glob": "^8.1.0",
|
|
||||||
"@types/kill-port": "^2.0.3",
|
|
||||||
"@types/node": "^22.14.0",
|
|
||||||
"@types/react": "^19.0.10",
|
|
||||||
"@types/react-dom": "^19.0.4",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
|
||||||
"@typescript-eslint/parser": "^5.62.0",
|
|
||||||
"@vitest/ui": "^3.1.1",
|
|
||||||
"cross-env": "^7.0.3",
|
|
||||||
"drizzle-kit": "^0.31.8",
|
|
||||||
"electron": "38.2.2",
|
|
||||||
"eslint": "^8.57.1",
|
|
||||||
"eslint-plugin-import": "^2.31.0",
|
|
||||||
"happy-dom": "^20.0.11",
|
|
||||||
"husky": "^9.1.7",
|
|
||||||
"lint-staged": "^15.5.2",
|
|
||||||
"oxlint": "^1.8.0",
|
|
||||||
"prettier": "3.5.3",
|
|
||||||
"rimraf": "^6.0.1",
|
|
||||||
"typescript": "^5.8.3",
|
|
||||||
"vite": "^7.3.0",
|
|
||||||
"vitest": "^3.1.1"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@ai-sdk/amazon-bedrock": "^3.0.15",
|
|
||||||
"@ai-sdk/anthropic": "^2.0.4",
|
|
||||||
"@ai-sdk/azure": "^2.0.17",
|
|
||||||
"@ai-sdk/google": "^2.0.6",
|
|
||||||
"@ai-sdk/google-vertex": "3.0.16",
|
|
||||||
"@ai-sdk/openai": "2.0.15",
|
|
||||||
"@ai-sdk/openai-compatible": "^1.0.8",
|
|
||||||
"@ai-sdk/provider-utils": "^3.0.3",
|
|
||||||
"@ai-sdk/xai": "^2.0.16",
|
|
||||||
"@babel/parser": "^7.28.5",
|
|
||||||
"@biomejs/biome": "^1.9.4",
|
|
||||||
"@dyad-sh/supabase-management-js": "v1.0.1",
|
|
||||||
"@lexical/react": "^0.33.1",
|
|
||||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
|
||||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
|
||||||
"@neondatabase/api-client": "^2.1.0",
|
|
||||||
"@neondatabase/serverless": "^1.0.1",
|
|
||||||
"@openrouter/ai-sdk-provider": "^1.1.2",
|
|
||||||
"@radix-ui/react-accordion": "^1.2.4",
|
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.13",
|
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.7",
|
|
||||||
"@radix-ui/react-label": "^2.1.4",
|
|
||||||
"@radix-ui/react-popover": "^1.1.7",
|
|
||||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
|
||||||
"@radix-ui/react-select": "^2.2.2",
|
|
||||||
"@radix-ui/react-separator": "^1.1.2",
|
|
||||||
"@radix-ui/react-slot": "^1.2.2",
|
|
||||||
"@radix-ui/react-switch": "^1.2.0",
|
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
|
||||||
"@radix-ui/react-toggle": "^1.1.3",
|
|
||||||
"@radix-ui/react-toggle-group": "^1.1.3",
|
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
|
||||||
"@rollup/plugin-commonjs": "^28.0.3",
|
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
|
||||||
"@tailwindcss/vite": "^4.1.3",
|
|
||||||
"@tanstack/react-query": "^5.75.5",
|
|
||||||
"@tanstack/react-router": "^1.114.34",
|
|
||||||
"@types/uuid": "^10.0.0",
|
|
||||||
"@vercel/sdk": "^1.18.0",
|
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
|
||||||
"ai": "^5.0.15",
|
|
||||||
"better-sqlite3": "^12.4.1",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"cmdk": "^1.1.1",
|
|
||||||
"date-fns": "^4.1.0",
|
|
||||||
"dotenv": "^16.4.7",
|
|
||||||
"drizzle-orm": "^0.41.0",
|
|
||||||
"dugite": "^3.0.0",
|
|
||||||
"electron-log": "^5.3.3",
|
|
||||||
"electron-playwright-helpers": "^1.7.1",
|
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
|
||||||
"esbuild-register": "^3.6.0",
|
|
||||||
"fastest-levenshtein": "^1.0.16",
|
|
||||||
"fix-path": "^4.0.0",
|
|
||||||
"framer-motion": "^12.6.3",
|
|
||||||
"geist": "^1.3.1",
|
|
||||||
"glob": "^11.0.2",
|
|
||||||
"html-to-image": "^1.11.13",
|
|
||||||
"isomorphic-git": "^1.30.1",
|
|
||||||
"jotai": "^2.12.2",
|
|
||||||
"kill-port": "^2.0.1",
|
|
||||||
"konva": "^10.0.12",
|
|
||||||
"lexical": "^0.33.1",
|
|
||||||
"lexical-beautiful-mentions": "^0.1.47",
|
|
||||||
"lucide-react": "^0.487.0",
|
|
||||||
"monaco-editor": "^0.52.2",
|
|
||||||
"openai": "^4.91.1",
|
|
||||||
"perfect-freehand": "^1.2.2",
|
|
||||||
"posthog-js": "^1.236.3",
|
|
||||||
"react": "^19.0.0",
|
|
||||||
"react-dom": "^19.0.0",
|
|
||||||
"react-konva": "^19.2.1",
|
|
||||||
"react-markdown": "^10.1.0",
|
|
||||||
"react-resizable-panels": "^2.1.7",
|
|
||||||
"react-shiki": "^0.9.0",
|
|
||||||
"recast": "^0.23.11",
|
|
||||||
"remark-gfm": "^4.0.1",
|
|
||||||
"shell-env": "^4.0.1",
|
|
||||||
"shiki": "^3.2.1",
|
|
||||||
"sonner": "^2.0.3",
|
|
||||||
"stacktrace-js": "^2.0.2",
|
|
||||||
"tailwind-merge": "^3.1.0",
|
|
||||||
"tailwindcss": "^4.1.3",
|
|
||||||
"tree-kill": "^1.2.2",
|
|
||||||
"tw-animate-css": "^1.2.5",
|
|
||||||
"update-electron-app": "^3.1.1",
|
|
||||||
"uuid": "^11.1.0",
|
|
||||||
"zod": "^3.25.76"
|
|
||||||
},
|
|
||||||
"lint-staged": {
|
|
||||||
"**/*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue,astro,svelte}": "oxlint",
|
|
||||||
"*.{js,css,md,ts,tsx,jsx,json}": "prettier --write"
|
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"@vercel/sdk": {
|
|
||||||
"@modelcontextprotocol/sdk": "$@modelcontextprotocol/sdk"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# Test Documentation
|
|
||||||
|
|
||||||
This directory contains unit tests for the Dyad application.
|
|
||||||
|
|
||||||
## Testing Setup
|
|
||||||
|
|
||||||
We use [Vitest](https://vitest.dev/) as our testing framework, which is designed to work well with Vite and modern JavaScript.
|
|
||||||
|
|
||||||
### Test Commands
|
|
||||||
|
|
||||||
Add these commands to your `package.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"test": "vitest run",
|
|
||||||
"test:watch": "vitest",
|
|
||||||
"test:ui": "vitest --ui"
|
|
||||||
```
|
|
||||||
|
|
||||||
- `npm run test` - Run tests once
|
|
||||||
- `npm run test:watch` - Run tests in watch mode (rerun when files change)
|
|
||||||
- `npm run test:ui` - Run tests with UI reporter
|
|
||||||
|
|
||||||
## Mocking Guidelines
|
|
||||||
|
|
||||||
### Mocking fs module
|
|
||||||
|
|
||||||
When mocking the `node:fs` module, use a default export in the mock:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
vi.mock("node:fs", async () => {
|
|
||||||
return {
|
|
||||||
default: {
|
|
||||||
mkdirSync: vi.fn(),
|
|
||||||
writeFileSync: vi.fn(),
|
|
||||||
// Add other fs methods as needed
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mocking isomorphic-git
|
|
||||||
|
|
||||||
When mocking isomorphic-git, provide a default export:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
vi.mock("isomorphic-git", () => ({
|
|
||||||
default: {
|
|
||||||
add: vi.fn().mockResolvedValue(undefined),
|
|
||||||
commit: vi.fn().mockResolvedValue(undefined),
|
|
||||||
// Add other git methods as needed
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing IPC Handlers
|
|
||||||
|
|
||||||
When testing IPC handlers, mock the Electron IPC system:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
vi.mock("electron", () => ({
|
|
||||||
ipcMain: {
|
|
||||||
handle: vi.fn(),
|
|
||||||
on: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adding New Tests
|
|
||||||
|
|
||||||
1. Create a new file with the `.test.ts` or `.spec.ts` extension
|
|
||||||
2. Import the functions you want to test
|
|
||||||
3. Mock any dependencies using `vi.mock()`
|
|
||||||
4. Write your test cases using `describe()` and `it()`
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
See `chat_stream_handlers.test.ts` for an example of testing IPC handlers with proper mocking.
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
||||||
|
|
||||||
exports[`problem_prompt > createConciseProblemFixPrompt > should format a concise prompt for multiple errors 1`] = `
|
|
||||||
"Fix these 2 TypeScript compile-time errors:
|
|
||||||
|
|
||||||
1. src/main.ts:5:12 - Cannot find module 'react-dom/client' or its corresponding type declarations. (TS2307)
|
|
||||||
\`\`\`
|
|
||||||
SNIPPET
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
2. src/components/Modal.tsx:35:20 - Property 'isOpen' does not exist on type 'IntrinsicAttributes & ModalProps'. (TS2339)
|
|
||||||
\`\`\`
|
|
||||||
SNIPPET
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
|
|
||||||
Please fix all errors in a concise way."
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`problem_prompt > createConciseProblemFixPrompt > should format a concise prompt for single error 1`] = `
|
|
||||||
"Fix these 1 TypeScript compile-time error:
|
|
||||||
|
|
||||||
1. src/App.tsx:10:5 - Cannot find name 'consol'. Did you mean 'console'? (TS2552)
|
|
||||||
\`\`\`
|
|
||||||
SNIPPET
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
|
|
||||||
Please fix all errors in a concise way."
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`problem_prompt > createConciseProblemFixPrompt > should return a short message when no problems exist 1`] = `"No TypeScript problems detected."`;
|
|
||||||
|
|
||||||
exports[`problem_prompt > createProblemFixPrompt > should format a single error correctly 1`] = `
|
|
||||||
"Fix these 1 TypeScript compile-time error:
|
|
||||||
|
|
||||||
1. src/components/Button.tsx:15:23 - Property 'onClick' does not exist on type 'ButtonProps'. (TS2339)
|
|
||||||
\`\`\`
|
|
||||||
SNIPPET
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
|
|
||||||
Please fix all errors in a concise way."
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`problem_prompt > createProblemFixPrompt > should format multiple errors across multiple files 1`] = `
|
|
||||||
"Fix these 4 TypeScript compile-time errors:
|
|
||||||
|
|
||||||
1. src/components/Button.tsx:15:23 - Property 'onClick' does not exist on type 'ButtonProps'. (TS2339)
|
|
||||||
\`\`\`
|
|
||||||
SNIPPET
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
2. src/components/Button.tsx:8:12 - Type 'string | undefined' is not assignable to type 'string'. (TS2322)
|
|
||||||
\`\`\`
|
|
||||||
SNIPPET
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
3. src/hooks/useApi.ts:42:5 - Argument of type 'unknown' is not assignable to parameter of type 'string'. (TS2345)
|
|
||||||
\`\`\`
|
|
||||||
SNIPPET
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
4. src/utils/helpers.ts:45:8 - Function lacks ending return statement and return type does not include 'undefined'. (TS2366)
|
|
||||||
\`\`\`
|
|
||||||
SNIPPET
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
|
|
||||||
Please fix all errors in a concise way."
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`problem_prompt > createProblemFixPrompt > should handle realistic React TypeScript errors 1`] = `
|
|
||||||
"Fix these 4 TypeScript compile-time errors:
|
|
||||||
|
|
||||||
1. src/components/UserProfile.tsx:12:35 - Type '{ children: string; }' is missing the following properties from type 'UserProfileProps': user, onEdit (TS2739)
|
|
||||||
\`\`\`
|
|
||||||
SNIPPET
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
2. src/components/UserProfile.tsx:25:15 - Object is possibly 'null'. (TS2531)
|
|
||||||
\`\`\`
|
|
||||||
SNIPPET
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
3. src/hooks/useLocalStorage.ts:18:12 - Type 'string | null' is not assignable to type 'T'. (TS2322)
|
|
||||||
\`\`\`
|
|
||||||
SNIPPET
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
4. src/types/api.ts:45:3 - Duplicate identifier 'UserRole'. (TS2300)
|
|
||||||
\`\`\`
|
|
||||||
SNIPPET
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
|
|
||||||
Please fix all errors in a concise way."
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`problem_prompt > createProblemFixPrompt > should return a message when no problems exist 1`] = `"No TypeScript problems detected."`;
|
|
||||||
|
|
||||||
exports[`problem_prompt > realistic TypeScript error scenarios > should handle common React + TypeScript errors 1`] = `
|
|
||||||
"Fix these 4 TypeScript compile-time errors:
|
|
||||||
|
|
||||||
1. src/components/ProductCard.tsx:22:18 - Property 'price' is missing in type '{ name: string; description: string; }' but required in type 'Product'. (TS2741)
|
|
||||||
\`\`\`
|
|
||||||
SNIPPET
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
2. src/components/SearchInput.tsx:15:45 - Type '(value: string) => void' is not assignable to type 'ChangeEventHandler<HTMLInputElement>'. (TS2322)
|
|
||||||
\`\`\`
|
|
||||||
SNIPPET
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
3. src/api/userService.ts:8:1 - Function lacks ending return statement and return type does not include 'undefined'. (TS2366)
|
|
||||||
\`\`\`
|
|
||||||
SNIPPET
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
4. src/utils/dataProcessor.ts:34:25 - Object is possibly 'undefined'. (TS2532)
|
|
||||||
\`\`\`
|
|
||||||
SNIPPET
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
|
|
||||||
Please fix all errors in a concise way."
|
|
||||||
`;
|
|
||||||
@@ -1,534 +0,0 @@
|
|||||||
import { parseEnvFile, serializeEnvFile } from "@/ipc/utils/app_env_var_utils";
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
|
|
||||||
describe("parseEnvFile", () => {
|
|
||||||
it("should parse basic key=value pairs", () => {
|
|
||||||
const content = `API_KEY=abc123
|
|
||||||
DATABASE_URL=postgres://localhost:5432/mydb
|
|
||||||
PORT=3000`;
|
|
||||||
|
|
||||||
const result = parseEnvFile(content);
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ key: "API_KEY", value: "abc123" },
|
|
||||||
{ key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" },
|
|
||||||
{ key: "PORT", value: "3000" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle quoted values and remove quotes", () => {
|
|
||||||
const content = `API_KEY="abc123"
|
|
||||||
DATABASE_URL='postgres://localhost:5432/mydb'
|
|
||||||
MESSAGE="Hello World"`;
|
|
||||||
|
|
||||||
const result = parseEnvFile(content);
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ key: "API_KEY", value: "abc123" },
|
|
||||||
{ key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" },
|
|
||||||
{ key: "MESSAGE", value: "Hello World" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should skip empty lines", () => {
|
|
||||||
const content = `API_KEY=abc123
|
|
||||||
|
|
||||||
DATABASE_URL=postgres://localhost:5432/mydb
|
|
||||||
|
|
||||||
|
|
||||||
PORT=3000`;
|
|
||||||
|
|
||||||
const result = parseEnvFile(content);
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ key: "API_KEY", value: "abc123" },
|
|
||||||
{ key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" },
|
|
||||||
{ key: "PORT", value: "3000" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should skip comment lines", () => {
|
|
||||||
const content = `# This is a comment
|
|
||||||
API_KEY=abc123
|
|
||||||
# Another comment
|
|
||||||
DATABASE_URL=postgres://localhost:5432/mydb
|
|
||||||
# PORT=3000 (commented out)
|
|
||||||
DEBUG=true`;
|
|
||||||
|
|
||||||
const result = parseEnvFile(content);
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ key: "API_KEY", value: "abc123" },
|
|
||||||
{ key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" },
|
|
||||||
{ key: "DEBUG", value: "true" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle values with spaces", () => {
|
|
||||||
const content = `MESSAGE="Hello World"
|
|
||||||
DESCRIPTION='This is a long description'
|
|
||||||
TITLE=My App Title`;
|
|
||||||
|
|
||||||
const result = parseEnvFile(content);
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ key: "MESSAGE", value: "Hello World" },
|
|
||||||
{ key: "DESCRIPTION", value: "This is a long description" },
|
|
||||||
{ key: "TITLE", value: "My App Title" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle values with special characters", () => {
|
|
||||||
const content = `PASSWORD="p@ssw0rd!#$%"
|
|
||||||
URL="https://example.com/api?key=123&secret=456"
|
|
||||||
REGEX="^[a-zA-Z0-9]+$"`;
|
|
||||||
|
|
||||||
const result = parseEnvFile(content);
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ key: "PASSWORD", value: "p@ssw0rd!#$%" },
|
|
||||||
{ key: "URL", value: "https://example.com/api?key=123&secret=456" },
|
|
||||||
{ key: "REGEX", value: "^[a-zA-Z0-9]+$" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle empty values", () => {
|
|
||||||
const content = `EMPTY_VAR=
|
|
||||||
QUOTED_EMPTY=""
|
|
||||||
ANOTHER_VAR=value`;
|
|
||||||
|
|
||||||
const result = parseEnvFile(content);
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ key: "EMPTY_VAR", value: "" },
|
|
||||||
{ key: "QUOTED_EMPTY", value: "" },
|
|
||||||
{ key: "ANOTHER_VAR", value: "value" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle values with equals signs", () => {
|
|
||||||
const content = `EQUATION="2+2=4"
|
|
||||||
CONNECTION_STRING="server=localhost;user=admin;password=secret"`;
|
|
||||||
|
|
||||||
const result = parseEnvFile(content);
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ key: "EQUATION", value: "2+2=4" },
|
|
||||||
{
|
|
||||||
key: "CONNECTION_STRING",
|
|
||||||
value: "server=localhost;user=admin;password=secret",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should trim whitespace around keys and values", () => {
|
|
||||||
const content = ` API_KEY = abc123
|
|
||||||
DATABASE_URL = "postgres://localhost:5432/mydb"
|
|
||||||
PORT = 3000 `;
|
|
||||||
|
|
||||||
const result = parseEnvFile(content);
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ key: "API_KEY", value: "abc123" },
|
|
||||||
{ key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" },
|
|
||||||
{ key: "PORT", value: "3000" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should skip malformed lines without equals sign", () => {
|
|
||||||
const content = `API_KEY=abc123
|
|
||||||
MALFORMED_LINE
|
|
||||||
DATABASE_URL=postgres://localhost:5432/mydb
|
|
||||||
ANOTHER_MALFORMED
|
|
||||||
PORT=3000`;
|
|
||||||
|
|
||||||
const result = parseEnvFile(content);
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ key: "API_KEY", value: "abc123" },
|
|
||||||
{ key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" },
|
|
||||||
{ key: "PORT", value: "3000" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should skip lines with equals sign at the beginning", () => {
|
|
||||||
const content = `API_KEY=abc123
|
|
||||||
=invalid_line
|
|
||||||
DATABASE_URL=postgres://localhost:5432/mydb`;
|
|
||||||
|
|
||||||
const result = parseEnvFile(content);
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ key: "API_KEY", value: "abc123" },
|
|
||||||
{ key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle mixed quote types in values", () => {
|
|
||||||
const content = `MESSAGE="He said 'Hello World'"
|
|
||||||
COMMAND='echo "Hello World"'`;
|
|
||||||
|
|
||||||
const result = parseEnvFile(content);
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ key: "MESSAGE", value: "He said 'Hello World'" },
|
|
||||||
{ key: "COMMAND", value: 'echo "Hello World"' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle empty content", () => {
|
|
||||||
const result = parseEnvFile("");
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle content with only comments and empty lines", () => {
|
|
||||||
const content = `# Comment 1
|
|
||||||
|
|
||||||
# Comment 2
|
|
||||||
|
|
||||||
# Comment 3`;
|
|
||||||
|
|
||||||
const result = parseEnvFile(content);
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle values that start with hash symbol when quoted", () => {
|
|
||||||
const content = `HASH_VALUE="#hashtag"
|
|
||||||
COMMENT_LIKE="# This looks like a comment but it's a value"
|
|
||||||
ACTUAL_COMMENT=value
|
|
||||||
# This is an actual comment`;
|
|
||||||
|
|
||||||
const result = parseEnvFile(content);
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ key: "HASH_VALUE", value: "#hashtag" },
|
|
||||||
{
|
|
||||||
key: "COMMENT_LIKE",
|
|
||||||
value: "# This looks like a comment but it's a value",
|
|
||||||
},
|
|
||||||
{ key: "ACTUAL_COMMENT", value: "value" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should skip comments that look like key=value pairs", () => {
|
|
||||||
const content = `API_KEY=abc123
|
|
||||||
# SECRET_KEY=should_be_ignored
|
|
||||||
DATABASE_URL=postgres://localhost:5432/mydb
|
|
||||||
# PORT=3000
|
|
||||||
DEBUG=true`;
|
|
||||||
|
|
||||||
const result = parseEnvFile(content);
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ key: "API_KEY", value: "abc123" },
|
|
||||||
{ key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" },
|
|
||||||
{ key: "DEBUG", value: "true" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle values containing comment symbols", () => {
|
|
||||||
const content = `GIT_COMMIT_MSG="feat: add new feature # closes #123"
|
|
||||||
SQL_QUERY="SELECT * FROM users WHERE id = 1 # Get user by ID"
|
|
||||||
MARKDOWN_HEADING="# Main Title"
|
|
||||||
SHELL_COMMENT="echo 'hello' # prints hello"`;
|
|
||||||
|
|
||||||
const result = parseEnvFile(content);
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ key: "GIT_COMMIT_MSG", value: "feat: add new feature # closes #123" },
|
|
||||||
{
|
|
||||||
key: "SQL_QUERY",
|
|
||||||
value: "SELECT * FROM users WHERE id = 1 # Get user by ID",
|
|
||||||
},
|
|
||||||
{ key: "MARKDOWN_HEADING", value: "# Main Title" },
|
|
||||||
{ key: "SHELL_COMMENT", value: "echo 'hello' # prints hello" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle inline comments after key=value pairs", () => {
|
|
||||||
const content = `API_KEY=abc123 # This is the API key
|
|
||||||
DATABASE_URL=postgres://localhost:5432/mydb # Database connection
|
|
||||||
PORT=3000 # Server port
|
|
||||||
DEBUG=true # Enable debug mode`;
|
|
||||||
|
|
||||||
const result = parseEnvFile(content);
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ key: "API_KEY", value: "abc123 # This is the API key" },
|
|
||||||
{
|
|
||||||
key: "DATABASE_URL",
|
|
||||||
value: "postgres://localhost:5432/mydb # Database connection",
|
|
||||||
},
|
|
||||||
{ key: "PORT", value: "3000 # Server port" },
|
|
||||||
{ key: "DEBUG", value: "true # Enable debug mode" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle quoted values with inline comments", () => {
|
|
||||||
const content = `MESSAGE="Hello World" # Greeting message
|
|
||||||
PASSWORD="secret#123" # Password with hash
|
|
||||||
URL="https://example.com#section" # URL with fragment`;
|
|
||||||
|
|
||||||
const result = parseEnvFile(content);
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ key: "MESSAGE", value: "Hello World" },
|
|
||||||
{ key: "PASSWORD", value: "secret#123" },
|
|
||||||
{ key: "URL", value: "https://example.com#section" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle complex mixed comment scenarios", () => {
|
|
||||||
const content = `# Configuration file
|
|
||||||
API_KEY=abc123
|
|
||||||
# Database settings
|
|
||||||
DATABASE_URL="postgres://localhost:5432/mydb"
|
|
||||||
# PORT=5432 (commented out)
|
|
||||||
DATABASE_NAME=myapp
|
|
||||||
|
|
||||||
# Feature flags
|
|
||||||
FEATURE_A=true # Enable feature A
|
|
||||||
FEATURE_B="false" # Disable feature B
|
|
||||||
# FEATURE_C=true (disabled)
|
|
||||||
|
|
||||||
# URLs with fragments
|
|
||||||
HOMEPAGE="https://example.com#home"
|
|
||||||
DOCS_URL=https://docs.example.com#getting-started # Documentation link`;
|
|
||||||
|
|
||||||
const result = parseEnvFile(content);
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ key: "API_KEY", value: "abc123" },
|
|
||||||
{ key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" },
|
|
||||||
{ key: "DATABASE_NAME", value: "myapp" },
|
|
||||||
{ key: "FEATURE_A", value: "true # Enable feature A" },
|
|
||||||
{ key: "FEATURE_B", value: "false" },
|
|
||||||
{ key: "HOMEPAGE", value: "https://example.com#home" },
|
|
||||||
{
|
|
||||||
key: "DOCS_URL",
|
|
||||||
value: "https://docs.example.com#getting-started # Documentation link",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("serializeEnvFile", () => {
|
|
||||||
it("should serialize basic key=value pairs", () => {
|
|
||||||
const envVars = [
|
|
||||||
{ key: "API_KEY", value: "abc123" },
|
|
||||||
{ key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" },
|
|
||||||
{ key: "PORT", value: "3000" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = serializeEnvFile(envVars);
|
|
||||||
expect(result).toBe(`API_KEY=abc123
|
|
||||||
DATABASE_URL=postgres://localhost:5432/mydb
|
|
||||||
PORT=3000`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should quote values with spaces", () => {
|
|
||||||
const envVars = [
|
|
||||||
{ key: "MESSAGE", value: "Hello World" },
|
|
||||||
{ key: "DESCRIPTION", value: "This is a long description" },
|
|
||||||
{ key: "SIMPLE", value: "no_spaces" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = serializeEnvFile(envVars);
|
|
||||||
expect(result).toBe(`MESSAGE="Hello World"
|
|
||||||
DESCRIPTION="This is a long description"
|
|
||||||
SIMPLE=no_spaces`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should quote values with special characters", () => {
|
|
||||||
const envVars = [
|
|
||||||
{ key: "PASSWORD", value: "p@ssw0rd!#$%" },
|
|
||||||
{ key: "URL", value: "https://example.com/api?key=123&secret=456" },
|
|
||||||
{ key: "SIMPLE", value: "simple123" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = serializeEnvFile(envVars);
|
|
||||||
expect(result).toBe(`PASSWORD="p@ssw0rd!#$%"
|
|
||||||
URL="https://example.com/api?key=123&secret=456"
|
|
||||||
SIMPLE=simple123`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should escape quotes in values", () => {
|
|
||||||
const envVars = [
|
|
||||||
{ key: "MESSAGE", value: 'He said "Hello World"' },
|
|
||||||
{ key: "COMMAND", value: 'echo "test"' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = serializeEnvFile(envVars);
|
|
||||||
expect(result).toBe(`MESSAGE="He said \\"Hello World\\""
|
|
||||||
COMMAND="echo \\"test\\""`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle empty values", () => {
|
|
||||||
const envVars = [
|
|
||||||
{ key: "EMPTY_VAR", value: "" },
|
|
||||||
{ key: "ANOTHER_VAR", value: "value" },
|
|
||||||
{ key: "ALSO_EMPTY", value: "" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = serializeEnvFile(envVars);
|
|
||||||
expect(result).toBe(`EMPTY_VAR=
|
|
||||||
ANOTHER_VAR=value
|
|
||||||
ALSO_EMPTY=`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should quote values with hash symbols", () => {
|
|
||||||
const envVars = [
|
|
||||||
{ key: "PASSWORD", value: "secret#123" },
|
|
||||||
{ key: "COMMENT", value: "This has # in it" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = serializeEnvFile(envVars);
|
|
||||||
expect(result).toBe(`PASSWORD="secret#123"
|
|
||||||
COMMENT="This has # in it"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should quote values with single quotes", () => {
|
|
||||||
const envVars = [
|
|
||||||
{ key: "MESSAGE", value: "Don't worry" },
|
|
||||||
{ key: "SQL", value: "SELECT * FROM 'users'" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = serializeEnvFile(envVars);
|
|
||||||
expect(result).toBe(`MESSAGE="Don't worry"
|
|
||||||
SQL="SELECT * FROM 'users'"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle values with equals signs", () => {
|
|
||||||
const envVars = [
|
|
||||||
{ key: "EQUATION", value: "2+2=4" },
|
|
||||||
{
|
|
||||||
key: "CONNECTION_STRING",
|
|
||||||
value: "server=localhost;user=admin;password=secret",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = serializeEnvFile(envVars);
|
|
||||||
expect(result).toBe(`EQUATION="2+2=4"
|
|
||||||
CONNECTION_STRING="server=localhost;user=admin;password=secret"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle mixed scenarios", () => {
|
|
||||||
const envVars = [
|
|
||||||
{ key: "SIMPLE", value: "value" },
|
|
||||||
{ key: "WITH_SPACES", value: "hello world" },
|
|
||||||
{ key: "WITH_QUOTES", value: 'say "hello"' },
|
|
||||||
{ key: "EMPTY", value: "" },
|
|
||||||
{ key: "SPECIAL_CHARS", value: "p@ssw0rd!#$%" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = serializeEnvFile(envVars);
|
|
||||||
expect(result).toBe(`SIMPLE=value
|
|
||||||
WITH_SPACES="hello world"
|
|
||||||
WITH_QUOTES="say \\"hello\\""
|
|
||||||
EMPTY=
|
|
||||||
SPECIAL_CHARS="p@ssw0rd!#$%"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle empty array", () => {
|
|
||||||
const result = serializeEnvFile([]);
|
|
||||||
expect(result).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle complex escaped quotes", () => {
|
|
||||||
const envVars = [
|
|
||||||
{ key: "COMPLEX", value: "This is \"complex\" with 'mixed' quotes" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = serializeEnvFile(envVars);
|
|
||||||
expect(result).toBe(`COMPLEX="This is \\"complex\\" with 'mixed' quotes"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle values that start with hash symbol", () => {
|
|
||||||
const envVars = [
|
|
||||||
{ key: "HASHTAG", value: "#trending" },
|
|
||||||
{ key: "COMMENT_LIKE", value: "# This looks like a comment" },
|
|
||||||
{ key: "MARKDOWN_HEADING", value: "# Main Title" },
|
|
||||||
{ key: "NORMAL_VALUE", value: "no_hash_here" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = serializeEnvFile(envVars);
|
|
||||||
expect(result).toBe(`HASHTAG="#trending"
|
|
||||||
COMMENT_LIKE="# This looks like a comment"
|
|
||||||
MARKDOWN_HEADING="# Main Title"
|
|
||||||
NORMAL_VALUE=no_hash_here`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle values containing comment symbols", () => {
|
|
||||||
const envVars = [
|
|
||||||
{ key: "GIT_COMMIT", value: "feat: add feature # closes #123" },
|
|
||||||
{ key: "SQL_QUERY", value: "SELECT * FROM users # Get all users" },
|
|
||||||
{ key: "SHELL_CMD", value: "echo 'hello' # prints hello" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = serializeEnvFile(envVars);
|
|
||||||
expect(result).toBe(`GIT_COMMIT="feat: add feature # closes #123"
|
|
||||||
SQL_QUERY="SELECT * FROM users # Get all users"
|
|
||||||
SHELL_CMD="echo 'hello' # prints hello"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle URLs with fragments that contain hash symbols", () => {
|
|
||||||
const envVars = [
|
|
||||||
{ key: "HOMEPAGE", value: "https://example.com#home" },
|
|
||||||
{ key: "DOCS_URL", value: "https://docs.example.com#getting-started" },
|
|
||||||
{ key: "API_ENDPOINT", value: "https://api.example.com/v1#section" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = serializeEnvFile(envVars);
|
|
||||||
expect(result).toBe(`HOMEPAGE="https://example.com#home"
|
|
||||||
DOCS_URL="https://docs.example.com#getting-started"
|
|
||||||
API_ENDPOINT="https://api.example.com/v1#section"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle values with hash symbols and other special characters", () => {
|
|
||||||
const envVars = [
|
|
||||||
{ key: "COMPLEX_PASSWORD", value: "p@ssw0rd#123!&" },
|
|
||||||
{ key: "REGEX_PATTERN", value: "^[a-zA-Z0-9#]+$" },
|
|
||||||
{
|
|
||||||
key: "MARKDOWN_CONTENT",
|
|
||||||
value: "# Title\n\nSome content with = and & symbols",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = serializeEnvFile(envVars);
|
|
||||||
expect(result).toBe(`COMPLEX_PASSWORD="p@ssw0rd#123!&"
|
|
||||||
REGEX_PATTERN="^[a-zA-Z0-9#]+$"
|
|
||||||
MARKDOWN_CONTENT="# Title\n\nSome content with = and & symbols"`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseEnvFile and serializeEnvFile integration", () => {
|
|
||||||
it("should be able to parse what it serializes", () => {
|
|
||||||
const originalEnvVars = [
|
|
||||||
{ key: "API_KEY", value: "abc123" },
|
|
||||||
{ key: "MESSAGE", value: "Hello World" },
|
|
||||||
{ key: "PASSWORD", value: 'secret"123' },
|
|
||||||
{ key: "EMPTY", value: "" },
|
|
||||||
{ key: "SPECIAL", value: "p@ssw0rd!#$%" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const serialized = serializeEnvFile(originalEnvVars);
|
|
||||||
const parsed = parseEnvFile(serialized);
|
|
||||||
|
|
||||||
expect(parsed).toEqual(originalEnvVars);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle round-trip with complex values", () => {
|
|
||||||
const originalEnvVars = [
|
|
||||||
{ key: "URL", value: "https://example.com/api?key=123&secret=456" },
|
|
||||||
{ key: "REGEX", value: "^[a-zA-Z0-9]+$" },
|
|
||||||
{ key: "COMMAND", value: 'echo "Hello World"' },
|
|
||||||
{ key: "EQUATION", value: "2+2=4" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const serialized = serializeEnvFile(originalEnvVars);
|
|
||||||
const parsed = parseEnvFile(serialized);
|
|
||||||
|
|
||||||
expect(parsed).toEqual(originalEnvVars);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle round-trip with comment-like values", () => {
|
|
||||||
const originalEnvVars = [
|
|
||||||
{ key: "HASHTAG", value: "#trending" },
|
|
||||||
{
|
|
||||||
key: "COMMENT_LIKE",
|
|
||||||
value: "# This looks like a comment but it's a value",
|
|
||||||
},
|
|
||||||
{ key: "GIT_COMMIT", value: "feat: add feature # closes #123" },
|
|
||||||
{ key: "URL_WITH_FRAGMENT", value: "https://example.com#section" },
|
|
||||||
{ key: "MARKDOWN_HEADING", value: "# Main Title" },
|
|
||||||
{ key: "COMPLEX_VALUE", value: "password#123=secret&token=abc" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const serialized = serializeEnvFile(originalEnvVars);
|
|
||||||
const parsed = parseEnvFile(serialized);
|
|
||||||
|
|
||||||
expect(parsed).toEqual(originalEnvVars);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,89 +0,0 @@
|
|||||||
import { cleanFullResponse } from "@/ipc/utils/cleanFullResponse";
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
|
|
||||||
describe("cleanFullResponse", () => {
|
|
||||||
it("should replace < characters in dyad-write attributes", () => {
|
|
||||||
const input = `<dyad-write path="src/file.tsx" description="Testing <a> tags.">content</dyad-write>`;
|
|
||||||
const expected = `<dyad-write path="src/file.tsx" description="Testing <a> tags.">content</dyad-write>`;
|
|
||||||
|
|
||||||
const result = cleanFullResponse(input);
|
|
||||||
expect(result).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should replace < characters in multiple attributes", () => {
|
|
||||||
const input = `<dyad-write path="src/<component>.tsx" description="Testing <div> tags.">content</dyad-write>`;
|
|
||||||
const expected = `<dyad-write path="src/<component>.tsx" description="Testing <div> tags.">content</dyad-write>`;
|
|
||||||
|
|
||||||
const result = cleanFullResponse(input);
|
|
||||||
expect(result).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle multiple nested HTML tags in a single attribute", () => {
|
|
||||||
const input = `<dyad-write path="src/file.tsx" description="Testing <div> and <span> and <a> tags.">content</dyad-write>`;
|
|
||||||
const expected = `<dyad-write path="src/file.tsx" description="Testing <div> and <span> and <a> tags.">content</dyad-write>`;
|
|
||||||
|
|
||||||
const result = cleanFullResponse(input);
|
|
||||||
expect(result).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle complex example with mixed content", () => {
|
|
||||||
const input = `
|
|
||||||
BEFORE TAG
|
|
||||||
<dyad-write path="src/pages/locations/neighborhoods/louisville/Highlands.tsx" description="Updating Highlands neighborhood page to use <a> tags.">
|
|
||||||
import React from 'react';
|
|
||||||
</dyad-write>
|
|
||||||
AFTER TAG
|
|
||||||
`;
|
|
||||||
|
|
||||||
const expected = `
|
|
||||||
BEFORE TAG
|
|
||||||
<dyad-write path="src/pages/locations/neighborhoods/louisville/Highlands.tsx" description="Updating Highlands neighborhood page to use <a> tags.">
|
|
||||||
import React from 'react';
|
|
||||||
</dyad-write>
|
|
||||||
AFTER TAG
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = cleanFullResponse(input);
|
|
||||||
expect(result).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle other dyad tag types", () => {
|
|
||||||
const input = `<dyad-rename from="src/<old>.tsx" to="src/<new>.tsx"></dyad-rename>`;
|
|
||||||
const expected = `<dyad-rename from="src/<old>.tsx" to="src/<new>.tsx"></dyad-rename>`;
|
|
||||||
|
|
||||||
const result = cleanFullResponse(input);
|
|
||||||
expect(result).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle dyad-delete tags", () => {
|
|
||||||
const input = `<dyad-delete path="src/<component>.tsx"></dyad-delete>`;
|
|
||||||
const expected = `<dyad-delete path="src/<component>.tsx"></dyad-delete>`;
|
|
||||||
|
|
||||||
const result = cleanFullResponse(input);
|
|
||||||
expect(result).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not affect content outside dyad tags", () => {
|
|
||||||
const input = `Some text with <regular> HTML tags. <dyad-write path="test.tsx" description="With <nested> tags.">content</dyad-write> More <html> here.`;
|
|
||||||
const expected = `Some text with <regular> HTML tags. <dyad-write path="test.tsx" description="With <nested> tags.">content</dyad-write> More <html> here.`;
|
|
||||||
|
|
||||||
const result = cleanFullResponse(input);
|
|
||||||
expect(result).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle empty attributes", () => {
|
|
||||||
const input = `<dyad-write path="src/file.tsx">content</dyad-write>`;
|
|
||||||
const expected = `<dyad-write path="src/file.tsx">content</dyad-write>`;
|
|
||||||
|
|
||||||
const result = cleanFullResponse(input);
|
|
||||||
expect(result).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle attributes without < characters", () => {
|
|
||||||
const input = `<dyad-write path="src/file.tsx" description="Normal description">content</dyad-write>`;
|
|
||||||
const expected = `<dyad-write path="src/file.tsx" description="Normal description">content</dyad-write>`;
|
|
||||||
|
|
||||||
const result = cleanFullResponse(input);
|
|
||||||
expect(result).toBe(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
import { formatMessagesForSummary } from "../ipc/handlers/chat_stream_handlers";
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
|
|
||||||
describe("formatMessagesForSummary", () => {
|
|
||||||
it("should return all messages when there are 8 or fewer messages", () => {
|
|
||||||
const messages = [
|
|
||||||
{ role: "user", content: "Hello" },
|
|
||||||
{ role: "assistant", content: "Hi there!" },
|
|
||||||
{ role: "user", content: "How are you?" },
|
|
||||||
{ role: "assistant", content: "I'm doing well, thanks!" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = formatMessagesForSummary(messages);
|
|
||||||
const expected = [
|
|
||||||
'<message role="user">Hello</message>',
|
|
||||||
'<message role="assistant">Hi there!</message>',
|
|
||||||
'<message role="user">How are you?</message>',
|
|
||||||
'<message role="assistant">I\'m doing well, thanks!</message>',
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
expect(result).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return all messages when there are exactly 8 messages", () => {
|
|
||||||
const messages = Array.from({ length: 8 }, (_, i) => ({
|
|
||||||
role: i % 2 === 0 ? "user" : "assistant",
|
|
||||||
content: `Message ${i + 1}`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const result = formatMessagesForSummary(messages);
|
|
||||||
const expected = messages
|
|
||||||
.map((m) => `<message role="${m.role}">${m.content}</message>`)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
expect(result).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should truncate messages when there are more than 8 messages", () => {
|
|
||||||
const messages = Array.from({ length: 12 }, (_, i) => ({
|
|
||||||
role: i % 2 === 0 ? "user" : "assistant",
|
|
||||||
content: `Message ${i + 1}`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const result = formatMessagesForSummary(messages);
|
|
||||||
|
|
||||||
// Should contain first 2 messages
|
|
||||||
expect(result).toContain('<message role="user">Message 1</message>');
|
|
||||||
expect(result).toContain('<message role="assistant">Message 2</message>');
|
|
||||||
|
|
||||||
// Should contain omission indicator
|
|
||||||
expect(result).toContain(
|
|
||||||
'<message role="system">[... 4 messages omitted ...]</message>',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should contain last 6 messages
|
|
||||||
expect(result).toContain('<message role="user">Message 7</message>');
|
|
||||||
expect(result).toContain('<message role="assistant">Message 8</message>');
|
|
||||||
expect(result).toContain('<message role="user">Message 9</message>');
|
|
||||||
expect(result).toContain('<message role="assistant">Message 10</message>');
|
|
||||||
expect(result).toContain('<message role="user">Message 11</message>');
|
|
||||||
expect(result).toContain('<message role="assistant">Message 12</message>');
|
|
||||||
|
|
||||||
// Should not contain middle messages
|
|
||||||
expect(result).not.toContain('<message role="user">Message 3</message>');
|
|
||||||
expect(result).not.toContain(
|
|
||||||
'<message role="assistant">Message 4</message>',
|
|
||||||
);
|
|
||||||
expect(result).not.toContain('<message role="user">Message 5</message>');
|
|
||||||
expect(result).not.toContain(
|
|
||||||
'<message role="assistant">Message 6</message>',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle messages with undefined content", () => {
|
|
||||||
const messages = [
|
|
||||||
{ role: "user", content: "Hello" },
|
|
||||||
{ role: "assistant", content: undefined },
|
|
||||||
{ role: "user", content: "Are you there?" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = formatMessagesForSummary(messages);
|
|
||||||
const expected = [
|
|
||||||
'<message role="user">Hello</message>',
|
|
||||||
'<message role="assistant">undefined</message>',
|
|
||||||
'<message role="user">Are you there?</message>',
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
expect(result).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle empty messages array", () => {
|
|
||||||
const messages: { role: string; content: string | undefined }[] = [];
|
|
||||||
const result = formatMessagesForSummary(messages);
|
|
||||||
expect(result).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle single message", () => {
|
|
||||||
const messages = [{ role: "user", content: "Hello world" }];
|
|
||||||
const result = formatMessagesForSummary(messages);
|
|
||||||
expect(result).toBe('<message role="user">Hello world</message>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should correctly calculate omitted messages count", () => {
|
|
||||||
const messages = Array.from({ length: 20 }, (_, i) => ({
|
|
||||||
role: i % 2 === 0 ? "user" : "assistant",
|
|
||||||
content: `Message ${i + 1}`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const result = formatMessagesForSummary(messages);
|
|
||||||
|
|
||||||
// Should indicate 12 messages omitted (20 total - 2 first - 6 last = 12)
|
|
||||||
expect(result).toContain(
|
|
||||||
'<message role="system">[... 12 messages omitted ...]</message>',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle messages with special characters in content", () => {
|
|
||||||
const messages = [
|
|
||||||
{ role: "user", content: 'Hello <world> & "friends"' },
|
|
||||||
{ role: "assistant", content: "Hi there! <tag>content</tag>" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = formatMessagesForSummary(messages);
|
|
||||||
|
|
||||||
// Should preserve special characters as-is (no HTML escaping)
|
|
||||||
expect(result).toContain(
|
|
||||||
'<message role="user">Hello <world> & "friends"</message>',
|
|
||||||
);
|
|
||||||
expect(result).toContain(
|
|
||||||
'<message role="assistant">Hi there! <tag>content</tag></message>',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should maintain message order in truncated output", () => {
|
|
||||||
const messages = Array.from({ length: 15 }, (_, i) => ({
|
|
||||||
role: i % 2 === 0 ? "user" : "assistant",
|
|
||||||
content: `Message ${i + 1}`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const result = formatMessagesForSummary(messages);
|
|
||||||
const lines = result.split("\n");
|
|
||||||
|
|
||||||
// Should have exactly 9 lines (2 first + 1 omission + 6 last)
|
|
||||||
expect(lines).toHaveLength(9);
|
|
||||||
|
|
||||||
// Check order: first 2, then omission, then last 6
|
|
||||||
expect(lines[0]).toBe('<message role="user">Message 1</message>');
|
|
||||||
expect(lines[1]).toBe('<message role="assistant">Message 2</message>');
|
|
||||||
expect(lines[2]).toBe(
|
|
||||||
'<message role="system">[... 7 messages omitted ...]</message>',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Last 6 messages are messages 10-15 (indices 9-14)
|
|
||||||
// Message 10 (index 9): 9 % 2 === 1, so "assistant"
|
|
||||||
// Message 11 (index 10): 10 % 2 === 0, so "user"
|
|
||||||
// Message 12 (index 11): 11 % 2 === 1, so "assistant"
|
|
||||||
// Message 13 (index 12): 12 % 2 === 0, so "user"
|
|
||||||
// Message 14 (index 13): 13 % 2 === 1, so "assistant"
|
|
||||||
// Message 15 (index 14): 14 % 2 === 0, so "user"
|
|
||||||
expect(lines[3]).toBe('<message role="assistant">Message 10</message>');
|
|
||||||
expect(lines[4]).toBe('<message role="user">Message 11</message>');
|
|
||||||
expect(lines[5]).toBe('<message role="assistant">Message 12</message>');
|
|
||||||
expect(lines[6]).toBe('<message role="user">Message 13</message>');
|
|
||||||
expect(lines[7]).toBe('<message role="assistant">Message 14</message>');
|
|
||||||
expect(lines[8]).toBe('<message role="user">Message 15</message>');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
import { parseAppMentions } from "@/shared/parse_mention_apps";
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
|
|
||||||
describe("parseAppMentions", () => {
|
|
||||||
it("should parse basic app mentions", () => {
|
|
||||||
const prompt = "Can you help me with @app:MyApp and @app:AnotherApp?";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["MyApp", "AnotherApp"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should parse app mentions with underscores", () => {
|
|
||||||
const prompt = "I need help with @app:my_app and @app:another_app_name";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["my_app", "another_app_name"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should parse app mentions with hyphens", () => {
|
|
||||||
const prompt = "Check @app:my-app and @app:another-app-name";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["my-app", "another-app-name"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should parse app mentions with numbers", () => {
|
|
||||||
const prompt = "Update @app:app1 and @app:app2023 please";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["app1", "app2023"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not parse mentions without app: prefix", () => {
|
|
||||||
const prompt = "Can you work on @MyApp and @AnotherApp?";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should require exact 'app:' prefix (case sensitive)", () => {
|
|
||||||
const prompt = "Check @App:MyApp and @APP:AnotherApp vs @app:ValidApp";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["ValidApp"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should parse mixed case app mentions", () => {
|
|
||||||
const prompt = "Help with @app:MyApp, @app:myapp, and @app:MYAPP";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["MyApp", "myapp", "MYAPP"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should parse app mentions with mixed characters (no spaces)", () => {
|
|
||||||
const prompt = "Check @app:My_App-2023 and @app:Another_App_Name-v2";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["My_App-2023", "Another_App_Name-v2"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not handle spaces in app names (spaces break app names)", () => {
|
|
||||||
const prompt = "Work on @app:My_App_Name with underscores";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["My_App_Name"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle empty string", () => {
|
|
||||||
const result = parseAppMentions("");
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle string with no mentions", () => {
|
|
||||||
const prompt = "This is just a regular message without any mentions";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle standalone @ symbol", () => {
|
|
||||||
const prompt = "This has @ symbol but no valid mention";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should ignore @ followed by special characters", () => {
|
|
||||||
const prompt = "Check @# and @! and @$ symbols";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should ignore @ at the end of string", () => {
|
|
||||||
const prompt = "This ends with @";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should parse mentions at different positions", () => {
|
|
||||||
const prompt =
|
|
||||||
"@app:StartApp in the beginning, @app:MiddleApp in middle, and @app:EndApp at end";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["StartApp", "MiddleApp", "EndApp"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle mentions with punctuation around them", () => {
|
|
||||||
const prompt = "Check (@app:MyApp), @app:AnotherApp! and @app:ThirdApp?";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["MyApp", "AnotherApp", "ThirdApp"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should parse mentions in different sentence structures", () => {
|
|
||||||
const prompt = `
|
|
||||||
Can you help me with @app:WebApp?
|
|
||||||
I also need @app:MobileApp updated.
|
|
||||||
Don't forget about @app:DesktopApp.
|
|
||||||
`;
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["WebApp", "MobileApp", "DesktopApp"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle duplicate mentions", () => {
|
|
||||||
const prompt = "Update @app:MyApp and also check @app:MyApp again";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["MyApp", "MyApp"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should parse mentions in multiline text", () => {
|
|
||||||
const prompt = `Line 1 has @app:App1
|
|
||||||
Line 2 has @app:App2
|
|
||||||
Line 3 has @app:App3`;
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["App1", "App2", "App3"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle mentions with tabs and other whitespace", () => {
|
|
||||||
const prompt = "Check\t@app:TabApp\nand\r@app:NewlineApp";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["TabApp", "NewlineApp"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should parse single character app names", () => {
|
|
||||||
const prompt = "Check @app:A and @app:B and @app:1";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["A", "B", "1"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle very long app names", () => {
|
|
||||||
const longAppName = "VeryLongAppNameWithManyCharacters123_test-app";
|
|
||||||
const prompt = `Check @app:${longAppName}`;
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual([longAppName]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should stop parsing at invalid characters", () => {
|
|
||||||
const prompt =
|
|
||||||
"Check @app:MyApp@InvalidPart and @app:AnotherApp.InvalidPart";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["MyApp", "AnotherApp"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle mentions with numbers and underscores mixed", () => {
|
|
||||||
const prompt = "Update @app:app_v1_2023 and @app:test_app_123";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["app_v1_2023", "test_app_123"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle mentions with hyphens and numbers mixed", () => {
|
|
||||||
const prompt = "Check @app:app-v1-2023 and @app:test-app-123";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["app-v1-2023", "test-app-123"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should parse mentions in URLs and complex text", () => {
|
|
||||||
const prompt =
|
|
||||||
"Visit https://example.com and check @app:WebApp for updates. Email admin@company.com about @app:MobileApp";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["WebApp", "MobileApp"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not handle spaces in app names (spaces break app names)", () => {
|
|
||||||
const prompt = "Check @app:My_App_Name with underscores";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["My_App_Name"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should parse mentions in JSON-like strings", () => {
|
|
||||||
const prompt = '{"app": "@app:MyApp", "another": "@app:SecondApp"}';
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["MyApp", "SecondApp"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle complex real-world scenarios (no spaces in app names)", () => {
|
|
||||||
const prompt = `
|
|
||||||
Hi there! I need help with @app:My_Web_App and @app:Mobile_App_v2.
|
|
||||||
Could you also check the status of @app:backend-service-2023?
|
|
||||||
Don't forget about @app:legacy_app and @app:NEW_PROJECT.
|
|
||||||
|
|
||||||
Thanks!
|
|
||||||
@app:user_mention should not be confused with @app:ActualApp.
|
|
||||||
`;
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual([
|
|
||||||
"My_Web_App",
|
|
||||||
"Mobile_App_v2",
|
|
||||||
"backend-service-2023",
|
|
||||||
"legacy_app",
|
|
||||||
"NEW_PROJECT",
|
|
||||||
"user_mention",
|
|
||||||
"ActualApp",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should preserve order of mentions", () => {
|
|
||||||
const prompt = "@app:Third @app:First @app:Second @app:Third @app:First";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["Third", "First", "Second", "Third", "First"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle edge case with @ followed by space", () => {
|
|
||||||
const prompt = "This has @ space but @app:ValidApp is here";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["ValidApp"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle unicode characters after @", () => {
|
|
||||||
const prompt = "Check @app:AppName and @app:测试 and @app:café-app";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
// Based on the regex, unicode characters like 测试 and é should not match
|
|
||||||
expect(result).toEqual(["AppName", "caf"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle nested mentions pattern", () => {
|
|
||||||
const prompt = "Check @app:App1 @app:App2 @app:App3 test";
|
|
||||||
const result = parseAppMentions(prompt);
|
|
||||||
expect(result).toEqual(["App1", "App2", "App3"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
import { parseOllamaHost } from "@/ipc/handlers/local_model_ollama_handler";
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
|
|
||||||
describe("parseOllamaHost", () => {
|
|
||||||
it("should return default URL when no host is provided", () => {
|
|
||||||
const result = parseOllamaHost();
|
|
||||||
expect(result).toBe("http://localhost:11434");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return default URL when host is undefined", () => {
|
|
||||||
const result = parseOllamaHost(undefined);
|
|
||||||
expect(result).toBe("http://localhost:11434");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return default URL when host is empty string", () => {
|
|
||||||
const result = parseOllamaHost("");
|
|
||||||
expect(result).toBe("http://localhost:11434");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("full URLs with protocol", () => {
|
|
||||||
it("should return http URLs as-is", () => {
|
|
||||||
const input = "http://localhost:11434";
|
|
||||||
const result = parseOllamaHost(input);
|
|
||||||
expect(result).toBe("http://localhost:11434");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return https URLs as-is", () => {
|
|
||||||
const input = "https://example.com:11434";
|
|
||||||
const result = parseOllamaHost(input);
|
|
||||||
expect(result).toBe("https://example.com:11434");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return http URLs with custom ports as-is", () => {
|
|
||||||
const input = "http://192.168.1.100:8080";
|
|
||||||
const result = parseOllamaHost(input);
|
|
||||||
expect(result).toBe("http://192.168.1.100:8080");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return https URLs with paths as-is", () => {
|
|
||||||
const input = "https://api.example.com:443/ollama";
|
|
||||||
const result = parseOllamaHost(input);
|
|
||||||
expect(result).toBe("https://api.example.com:443/ollama");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("hostname with port", () => {
|
|
||||||
it("should add http protocol to IPv4 host with port", () => {
|
|
||||||
const input = "192.168.1.100:8080";
|
|
||||||
const result = parseOllamaHost(input);
|
|
||||||
expect(result).toBe("http://192.168.1.100:8080");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add http protocol to localhost with custom port", () => {
|
|
||||||
const input = "localhost:8080";
|
|
||||||
const result = parseOllamaHost(input);
|
|
||||||
expect(result).toBe("http://localhost:8080");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add http protocol to domain with port", () => {
|
|
||||||
const input = "ollama.example.com:11434";
|
|
||||||
const result = parseOllamaHost(input);
|
|
||||||
expect(result).toBe("http://ollama.example.com:11434");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add http protocol to 0.0.0.0 with port", () => {
|
|
||||||
const input = "0.0.0.0:1234";
|
|
||||||
const result = parseOllamaHost(input);
|
|
||||||
expect(result).toBe("http://0.0.0.0:1234");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle IPv6 with port", () => {
|
|
||||||
const input = "[::1]:8080";
|
|
||||||
const result = parseOllamaHost(input);
|
|
||||||
expect(result).toBe("http://[::1]:8080");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("hostname only", () => {
|
|
||||||
it("should add http protocol and default port to IPv4 host", () => {
|
|
||||||
const input = "192.168.1.100";
|
|
||||||
const result = parseOllamaHost(input);
|
|
||||||
expect(result).toBe("http://192.168.1.100:11434");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add http protocol and default port to localhost", () => {
|
|
||||||
const input = "localhost";
|
|
||||||
const result = parseOllamaHost(input);
|
|
||||||
expect(result).toBe("http://localhost:11434");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add http protocol and default port to domain", () => {
|
|
||||||
const input = "ollama.example.com";
|
|
||||||
const result = parseOllamaHost(input);
|
|
||||||
expect(result).toBe("http://ollama.example.com:11434");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add http protocol and default port to 0.0.0.0", () => {
|
|
||||||
const input = "0.0.0.0";
|
|
||||||
const result = parseOllamaHost(input);
|
|
||||||
expect(result).toBe("http://0.0.0.0:11434");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle IPv6 hostname", () => {
|
|
||||||
const input = "::1";
|
|
||||||
const result = parseOllamaHost(input);
|
|
||||||
expect(result).toBe("http://[::1]:11434");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle full IPv6 hostname", () => {
|
|
||||||
const input = "2001:db8:85a3:0:0:8a2e:370:7334";
|
|
||||||
const result = parseOllamaHost(input);
|
|
||||||
expect(result).toBe("http://[2001:db8:85a3:0:0:8a2e:370:7334]:11434");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle compressed IPv6 hostname", () => {
|
|
||||||
const input = "2001:db8::1";
|
|
||||||
const result = parseOllamaHost(input);
|
|
||||||
expect(result).toBe("http://[2001:db8::1]:11434");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("edge cases", () => {
|
|
||||||
it("should handle hostname with unusual characters", () => {
|
|
||||||
const input = "my-ollama-server";
|
|
||||||
const result = parseOllamaHost(input);
|
|
||||||
expect(result).toBe("http://my-ollama-server:11434");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle hostname with dots", () => {
|
|
||||||
const input = "my.ollama.server";
|
|
||||||
const result = parseOllamaHost(input);
|
|
||||||
expect(result).toBe("http://my.ollama.server:11434");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle port 80", () => {
|
|
||||||
const input = "example.com:80";
|
|
||||||
const result = parseOllamaHost(input);
|
|
||||||
expect(result).toBe("http://example.com:80");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle port 443", () => {
|
|
||||||
const input = "example.com:443";
|
|
||||||
const result = parseOllamaHost(input);
|
|
||||||
expect(result).toBe("http://example.com:443");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
import { safeJoin } from "@/ipc/utils/path_utils";
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import path from "node:path";
|
|
||||||
import os from "node:os";
|
|
||||||
|
|
||||||
describe("safeJoin", () => {
|
|
||||||
const testBaseDir = "/app/workspace";
|
|
||||||
const testBaseDirWindows = "C:\\app\\workspace";
|
|
||||||
|
|
||||||
describe("safe paths", () => {
|
|
||||||
it("should join simple relative paths", () => {
|
|
||||||
const result = safeJoin(testBaseDir, "src", "components", "Button.tsx");
|
|
||||||
expect(result).toBe(
|
|
||||||
path.join(testBaseDir, "src", "components", "Button.tsx"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle single file names", () => {
|
|
||||||
const result = safeJoin(testBaseDir, "package.json");
|
|
||||||
expect(result).toBe(path.join(testBaseDir, "package.json"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle nested directories", () => {
|
|
||||||
const result = safeJoin(testBaseDir, "src/pages/home/index.tsx");
|
|
||||||
expect(result).toBe(path.join(testBaseDir, "src/pages/home/index.tsx"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle paths with dots in filename", () => {
|
|
||||||
const result = safeJoin(testBaseDir, "config.test.js");
|
|
||||||
expect(result).toBe(path.join(testBaseDir, "config.test.js"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle empty path segments", () => {
|
|
||||||
const result = safeJoin(testBaseDir, "", "src", "", "file.ts");
|
|
||||||
expect(result).toBe(path.join(testBaseDir, "", "src", "", "file.ts"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle multiple path segments", () => {
|
|
||||||
const result = safeJoin(testBaseDir, "a", "b", "c", "d", "file.txt");
|
|
||||||
expect(result).toBe(
|
|
||||||
path.join(testBaseDir, "a", "b", "c", "d", "file.txt"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should work with actual temp directory", () => {
|
|
||||||
const tempDir = os.tmpdir();
|
|
||||||
const result = safeJoin(tempDir, "test", "file.txt");
|
|
||||||
expect(result).toBe(path.join(tempDir, "test", "file.txt"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle Windows-style relative paths with backslashes", () => {
|
|
||||||
const result = safeJoin(testBaseDir, "src\\components\\Button.tsx");
|
|
||||||
expect(result).toBe(
|
|
||||||
path.join(testBaseDir, "src\\components\\Button.tsx"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle mixed forward/backslashes in relative paths", () => {
|
|
||||||
const result = safeJoin(testBaseDir, "src/components\\ui/button.tsx");
|
|
||||||
expect(result).toBe(
|
|
||||||
path.join(testBaseDir, "src/components\\ui/button.tsx"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle Windows-style nested directories", () => {
|
|
||||||
const result = safeJoin(
|
|
||||||
testBaseDir,
|
|
||||||
"pages\\home\\components\\index.tsx",
|
|
||||||
);
|
|
||||||
expect(result).toBe(
|
|
||||||
path.join(testBaseDir, "pages\\home\\components\\index.tsx"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle relative paths starting with dot and backslash", () => {
|
|
||||||
const result = safeJoin(testBaseDir, ".\\src\\file.txt");
|
|
||||||
expect(result).toBe(path.join(testBaseDir, ".\\src\\file.txt"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("unsafe paths - directory traversal", () => {
|
|
||||||
it("should throw on simple parent directory traversal", () => {
|
|
||||||
expect(() => safeJoin(testBaseDir, "../outside.txt")).toThrow(
|
|
||||||
/would escape the base directory/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw on multiple parent directory traversals", () => {
|
|
||||||
expect(() => safeJoin(testBaseDir, "../../etc/passwd")).toThrow(
|
|
||||||
/would escape the base directory/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw on complex traversal paths", () => {
|
|
||||||
expect(() => safeJoin(testBaseDir, "src/../../../etc/passwd")).toThrow(
|
|
||||||
/would escape the base directory/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw on mixed traversal with valid components", () => {
|
|
||||||
expect(() =>
|
|
||||||
safeJoin(
|
|
||||||
testBaseDir,
|
|
||||||
"src",
|
|
||||||
"components",
|
|
||||||
"..",
|
|
||||||
"..",
|
|
||||||
"..",
|
|
||||||
"outside.txt",
|
|
||||||
),
|
|
||||||
).toThrow(/would escape the base directory/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw on absolute Unix paths", () => {
|
|
||||||
expect(() => safeJoin(testBaseDir, "/etc/passwd")).toThrow(
|
|
||||||
/would escape the base directory/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw on absolute Windows paths", () => {
|
|
||||||
expect(() =>
|
|
||||||
safeJoin(testBaseDir, "C:\\Windows\\System32\\config"),
|
|
||||||
).toThrow(/would escape the base directory/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw on Windows UNC paths", () => {
|
|
||||||
expect(() =>
|
|
||||||
safeJoin(testBaseDir, "\\\\server\\share\\file.txt"),
|
|
||||||
).toThrow(/would escape the base directory/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw on home directory shortcuts", () => {
|
|
||||||
expect(() => safeJoin(testBaseDir, "~/secrets.txt")).toThrow(
|
|
||||||
/would escape the base directory/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("edge cases", () => {
|
|
||||||
it("should handle Windows-style base paths", () => {
|
|
||||||
const result = safeJoin(testBaseDirWindows, "src", "file.txt");
|
|
||||||
expect(result).toBe(path.join(testBaseDirWindows, "src", "file.txt"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw on Windows traversal from Unix base", () => {
|
|
||||||
expect(() => safeJoin(testBaseDir, "..\\..\\file.txt")).toThrow(
|
|
||||||
/would escape the base directory/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle current directory references safely", () => {
|
|
||||||
const result = safeJoin(testBaseDir, "./src/file.txt");
|
|
||||||
expect(result).toBe(path.join(testBaseDir, "./src/file.txt"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle nested current directory references", () => {
|
|
||||||
const result = safeJoin(testBaseDir, "src/./components/./Button.tsx");
|
|
||||||
expect(result).toBe(
|
|
||||||
path.join(testBaseDir, "src/./components/./Button.tsx"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw when current dir plus traversal escapes", () => {
|
|
||||||
expect(() => safeJoin(testBaseDir, "./../../outside.txt")).toThrow(
|
|
||||||
/would escape the base directory/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle very long paths safely", () => {
|
|
||||||
const longPath = Array(50).fill("subdir").join("/") + "/file.txt";
|
|
||||||
const result = safeJoin(testBaseDir, longPath);
|
|
||||||
expect(result).toBe(path.join(testBaseDir, longPath));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should allow Windows-style paths that look like drive letters but aren't", () => {
|
|
||||||
// These look like they could be problematic but are actually safe relative paths
|
|
||||||
const result1 = safeJoin(testBaseDir, "C_drive\\file.txt");
|
|
||||||
expect(result1).toBe(path.join(testBaseDir, "C_drive\\file.txt"));
|
|
||||||
|
|
||||||
const result2 = safeJoin(testBaseDir, "src\\C-file.txt");
|
|
||||||
expect(result2).toBe(path.join(testBaseDir, "src\\C-file.txt"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle Windows paths with multiple backslashes (not UNC)", () => {
|
|
||||||
// Single backslashes in the middle are fine - it's only \\ at the start that's UNC
|
|
||||||
const result = safeJoin(testBaseDir, "src\\\\components\\\\Button.tsx");
|
|
||||||
expect(result).toBe(
|
|
||||||
path.join(testBaseDir, "src\\\\components\\\\Button.tsx"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should provide descriptive error messages", () => {
|
|
||||||
expect(() => safeJoin("/base", "../outside.txt")).toThrow(
|
|
||||||
'Unsafe path: joining "../outside.txt" with base "/base" would escape the base directory',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should provide descriptive error for multiple segments", () => {
|
|
||||||
expect(() => safeJoin("/base", "src", "..", "..", "outside.txt")).toThrow(
|
|
||||||
'Unsafe path: joining "src, .., .., outside.txt" with base "/base" would escape the base directory',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("boundary conditions", () => {
|
|
||||||
it("should allow paths at the exact boundary", () => {
|
|
||||||
const result = safeJoin(testBaseDir, ".");
|
|
||||||
expect(result).toBe(path.join(testBaseDir, "."));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle paths that approach but don't cross boundary", () => {
|
|
||||||
const result = safeJoin(testBaseDir, "deep/nested/../file.txt");
|
|
||||||
expect(result).toBe(path.join(testBaseDir, "deep/nested/../file.txt"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle root directory as base", () => {
|
|
||||||
const result = safeJoin("/", "tmp/file.txt");
|
|
||||||
expect(result).toBe(path.join("/", "tmp/file.txt"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw when trying to escape root", () => {
|
|
||||||
expect(() => safeJoin("/tmp", "../etc/passwd")).toThrow(
|
|
||||||
/would escape the base directory/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { createProblemFixPrompt } from "../shared/problem_prompt";
|
|
||||||
import type { ProblemReport } from "../ipc/ipc_types";
|
|
||||||
|
|
||||||
const snippet = `SNIPPET`;
|
|
||||||
|
|
||||||
describe("problem_prompt", () => {
|
|
||||||
describe("createProblemFixPrompt", () => {
|
|
||||||
it("should return a message when no problems exist", () => {
|
|
||||||
const problemReport: ProblemReport = {
|
|
||||||
problems: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = createProblemFixPrompt(problemReport);
|
|
||||||
expect(result).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should format a single error correctly", () => {
|
|
||||||
const problemReport: ProblemReport = {
|
|
||||||
problems: [
|
|
||||||
{
|
|
||||||
file: "src/components/Button.tsx",
|
|
||||||
line: 15,
|
|
||||||
column: 23,
|
|
||||||
message: "Property 'onClick' does not exist on type 'ButtonProps'.",
|
|
||||||
code: 2339,
|
|
||||||
snippet,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = createProblemFixPrompt(problemReport);
|
|
||||||
expect(result).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should format multiple errors across multiple files", () => {
|
|
||||||
const problemReport: ProblemReport = {
|
|
||||||
problems: [
|
|
||||||
{
|
|
||||||
file: "src/components/Button.tsx",
|
|
||||||
line: 15,
|
|
||||||
column: 23,
|
|
||||||
message: "Property 'onClick' does not exist on type 'ButtonProps'.",
|
|
||||||
code: 2339,
|
|
||||||
snippet,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "src/components/Button.tsx",
|
|
||||||
line: 8,
|
|
||||||
column: 12,
|
|
||||||
message:
|
|
||||||
"Type 'string | undefined' is not assignable to type 'string'.",
|
|
||||||
code: 2322,
|
|
||||||
snippet,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "src/hooks/useApi.ts",
|
|
||||||
line: 42,
|
|
||||||
column: 5,
|
|
||||||
message:
|
|
||||||
"Argument of type 'unknown' is not assignable to parameter of type 'string'.",
|
|
||||||
code: 2345,
|
|
||||||
snippet,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "src/utils/helpers.ts",
|
|
||||||
line: 45,
|
|
||||||
column: 8,
|
|
||||||
message:
|
|
||||||
"Function lacks ending return statement and return type does not include 'undefined'.",
|
|
||||||
code: 2366,
|
|
||||||
snippet,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = createProblemFixPrompt(problemReport);
|
|
||||||
expect(result).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle realistic React TypeScript errors", () => {
|
|
||||||
const problemReport: ProblemReport = {
|
|
||||||
problems: [
|
|
||||||
{
|
|
||||||
file: "src/components/UserProfile.tsx",
|
|
||||||
line: 12,
|
|
||||||
column: 35,
|
|
||||||
message:
|
|
||||||
"Type '{ children: string; }' is missing the following properties from type 'UserProfileProps': user, onEdit",
|
|
||||||
code: 2739,
|
|
||||||
snippet,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "src/components/UserProfile.tsx",
|
|
||||||
line: 25,
|
|
||||||
column: 15,
|
|
||||||
message: "Object is possibly 'null'.",
|
|
||||||
code: 2531,
|
|
||||||
snippet,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "src/hooks/useLocalStorage.ts",
|
|
||||||
line: 18,
|
|
||||||
column: 12,
|
|
||||||
message: "Type 'string | null' is not assignable to type 'T'.",
|
|
||||||
code: 2322,
|
|
||||||
snippet,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "src/types/api.ts",
|
|
||||||
line: 45,
|
|
||||||
column: 3,
|
|
||||||
message: "Duplicate identifier 'UserRole'.",
|
|
||||||
code: 2300,
|
|
||||||
snippet,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = createProblemFixPrompt(problemReport);
|
|
||||||
expect(result).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createConciseProblemFixPrompt", () => {
|
|
||||||
it("should return a short message when no problems exist", () => {
|
|
||||||
const problemReport: ProblemReport = {
|
|
||||||
problems: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = createProblemFixPrompt(problemReport);
|
|
||||||
expect(result).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should format a concise prompt for single error", () => {
|
|
||||||
const problemReport: ProblemReport = {
|
|
||||||
problems: [
|
|
||||||
{
|
|
||||||
file: "src/App.tsx",
|
|
||||||
line: 10,
|
|
||||||
column: 5,
|
|
||||||
message: "Cannot find name 'consol'. Did you mean 'console'?",
|
|
||||||
code: 2552,
|
|
||||||
snippet,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = createProblemFixPrompt(problemReport);
|
|
||||||
expect(result).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should format a concise prompt for multiple errors", () => {
|
|
||||||
const problemReport: ProblemReport = {
|
|
||||||
problems: [
|
|
||||||
{
|
|
||||||
file: "src/main.ts",
|
|
||||||
line: 5,
|
|
||||||
column: 12,
|
|
||||||
message:
|
|
||||||
"Cannot find module 'react-dom/client' or its corresponding type declarations.",
|
|
||||||
code: 2307,
|
|
||||||
snippet,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "src/components/Modal.tsx",
|
|
||||||
line: 35,
|
|
||||||
column: 20,
|
|
||||||
message:
|
|
||||||
"Property 'isOpen' does not exist on type 'IntrinsicAttributes & ModalProps'.",
|
|
||||||
code: 2339,
|
|
||||||
snippet,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = createProblemFixPrompt(problemReport);
|
|
||||||
expect(result).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("realistic TypeScript error scenarios", () => {
|
|
||||||
it("should handle common React + TypeScript errors", () => {
|
|
||||||
const problemReport: ProblemReport = {
|
|
||||||
problems: [
|
|
||||||
// Missing interface property
|
|
||||||
{
|
|
||||||
file: "src/components/ProductCard.tsx",
|
|
||||||
line: 22,
|
|
||||||
column: 18,
|
|
||||||
message:
|
|
||||||
"Property 'price' is missing in type '{ name: string; description: string; }' but required in type 'Product'.",
|
|
||||||
code: 2741,
|
|
||||||
snippet,
|
|
||||||
},
|
|
||||||
// Incorrect event handler type
|
|
||||||
{
|
|
||||||
file: "src/components/SearchInput.tsx",
|
|
||||||
line: 15,
|
|
||||||
column: 45,
|
|
||||||
message:
|
|
||||||
"Type '(value: string) => void' is not assignable to type 'ChangeEventHandler<HTMLInputElement>'.",
|
|
||||||
code: 2322,
|
|
||||||
snippet,
|
|
||||||
},
|
|
||||||
// Async/await without Promise return type
|
|
||||||
{
|
|
||||||
file: "src/api/userService.ts",
|
|
||||||
line: 8,
|
|
||||||
column: 1,
|
|
||||||
message:
|
|
||||||
"Function lacks ending return statement and return type does not include 'undefined'.",
|
|
||||||
code: 2366,
|
|
||||||
snippet,
|
|
||||||
},
|
|
||||||
// Strict null check
|
|
||||||
{
|
|
||||||
file: "src/utils/dataProcessor.ts",
|
|
||||||
line: 34,
|
|
||||||
column: 25,
|
|
||||||
message: "Object is possibly 'undefined'.",
|
|
||||||
code: 2532,
|
|
||||||
snippet,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = createProblemFixPrompt(problemReport);
|
|
||||||
expect(result).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,409 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { safeStorage } from "electron";
|
|
||||||
import { readSettings, getSettingsFilePath } from "@/main/settings";
|
|
||||||
import { getUserDataPath } from "@/paths/paths";
|
|
||||||
import { UserSettings } from "@/lib/schemas";
|
|
||||||
|
|
||||||
// Mock dependencies
|
|
||||||
vi.mock("node:fs");
|
|
||||||
vi.mock("node:path");
|
|
||||||
vi.mock("electron", () => ({
|
|
||||||
safeStorage: {
|
|
||||||
isEncryptionAvailable: vi.fn(),
|
|
||||||
decryptString: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
vi.mock("@/paths/paths", () => ({
|
|
||||||
getUserDataPath: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockFs = vi.mocked(fs);
|
|
||||||
const mockPath = vi.mocked(path);
|
|
||||||
const mockSafeStorage = vi.mocked(safeStorage);
|
|
||||||
const mockGetUserDataPath = vi.mocked(getUserDataPath);
|
|
||||||
|
|
||||||
describe("readSettings", () => {
|
|
||||||
const mockUserDataPath = "/mock/user/data";
|
|
||||||
const mockSettingsPath = "/mock/user/data/user-settings.json";
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockGetUserDataPath.mockReturnValue(mockUserDataPath);
|
|
||||||
mockPath.join.mockReturnValue(mockSettingsPath);
|
|
||||||
mockSafeStorage.isEncryptionAvailable.mockReturnValue(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when settings file does not exist", () => {
|
|
||||||
it("should create default settings file and return default settings", () => {
|
|
||||||
mockFs.existsSync.mockReturnValue(false);
|
|
||||||
mockFs.writeFileSync.mockImplementation(() => {});
|
|
||||||
|
|
||||||
const result = readSettings();
|
|
||||||
|
|
||||||
expect(mockFs.existsSync).toHaveBeenCalledWith(mockSettingsPath);
|
|
||||||
expect(mockFs.writeFileSync).toHaveBeenCalledWith(
|
|
||||||
mockSettingsPath,
|
|
||||||
expect.stringContaining('"selectedModel"'),
|
|
||||||
);
|
|
||||||
expect(scrubSettings(result)).toMatchInlineSnapshot(`
|
|
||||||
{
|
|
||||||
"enableAutoFixProblems": false,
|
|
||||||
"enableAutoUpdate": true,
|
|
||||||
"enableProLazyEditsMode": true,
|
|
||||||
"enableProSmartFilesContextMode": true,
|
|
||||||
"experiments": {},
|
|
||||||
"hasRunBefore": false,
|
|
||||||
"isRunning": false,
|
|
||||||
"lastKnownPerformance": undefined,
|
|
||||||
"providerSettings": {},
|
|
||||||
"releaseChannel": "stable",
|
|
||||||
"selectedChatMode": "build",
|
|
||||||
"selectedModel": {
|
|
||||||
"name": "auto",
|
|
||||||
"provider": "auto",
|
|
||||||
},
|
|
||||||
"selectedTemplateId": "react",
|
|
||||||
"telemetryConsent": "unset",
|
|
||||||
"telemetryUserId": "[scrubbed]",
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when settings file exists", () => {
|
|
||||||
it("should read and merge settings with defaults", () => {
|
|
||||||
const mockFileContent = {
|
|
||||||
selectedModel: {
|
|
||||||
name: "gpt-4",
|
|
||||||
provider: "openai",
|
|
||||||
},
|
|
||||||
telemetryConsent: "opted_in",
|
|
||||||
hasRunBefore: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
mockFs.existsSync.mockReturnValue(true);
|
|
||||||
mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent));
|
|
||||||
|
|
||||||
const result = readSettings();
|
|
||||||
|
|
||||||
expect(mockFs.readFileSync).toHaveBeenCalledWith(
|
|
||||||
mockSettingsPath,
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
expect(result.selectedModel).toEqual({
|
|
||||||
name: "gpt-4",
|
|
||||||
provider: "openai",
|
|
||||||
});
|
|
||||||
expect(result.telemetryConsent).toBe("opted_in");
|
|
||||||
expect(result.hasRunBefore).toBe(true);
|
|
||||||
// Should still have defaults for missing properties
|
|
||||||
expect(result.enableAutoUpdate).toBe(true);
|
|
||||||
expect(result.releaseChannel).toBe("stable");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should decrypt encrypted provider API keys", () => {
|
|
||||||
const mockFileContent = {
|
|
||||||
providerSettings: {
|
|
||||||
openai: {
|
|
||||||
apiKey: {
|
|
||||||
value: "encrypted-api-key",
|
|
||||||
encryptionType: "electron-safe-storage",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mockFs.existsSync.mockReturnValue(true);
|
|
||||||
mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent));
|
|
||||||
mockSafeStorage.decryptString.mockReturnValue("decrypted-api-key");
|
|
||||||
|
|
||||||
const result = readSettings();
|
|
||||||
|
|
||||||
expect(mockSafeStorage.decryptString).toHaveBeenCalledWith(
|
|
||||||
Buffer.from("encrypted-api-key", "base64"),
|
|
||||||
);
|
|
||||||
expect(result.providerSettings.openai.apiKey).toEqual({
|
|
||||||
value: "decrypted-api-key",
|
|
||||||
encryptionType: "electron-safe-storage",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should decrypt encrypted GitHub access token", () => {
|
|
||||||
const mockFileContent = {
|
|
||||||
githubAccessToken: {
|
|
||||||
value: "encrypted-github-token",
|
|
||||||
encryptionType: "electron-safe-storage",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mockFs.existsSync.mockReturnValue(true);
|
|
||||||
mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent));
|
|
||||||
mockSafeStorage.decryptString.mockReturnValue("decrypted-github-token");
|
|
||||||
|
|
||||||
const result = readSettings();
|
|
||||||
|
|
||||||
expect(mockSafeStorage.decryptString).toHaveBeenCalledWith(
|
|
||||||
Buffer.from("encrypted-github-token", "base64"),
|
|
||||||
);
|
|
||||||
expect(result.githubAccessToken).toEqual({
|
|
||||||
value: "decrypted-github-token",
|
|
||||||
encryptionType: "electron-safe-storage",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should decrypt encrypted Supabase tokens", () => {
|
|
||||||
const mockFileContent = {
|
|
||||||
supabase: {
|
|
||||||
accessToken: {
|
|
||||||
value: "encrypted-access-token",
|
|
||||||
encryptionType: "electron-safe-storage",
|
|
||||||
},
|
|
||||||
refreshToken: {
|
|
||||||
value: "encrypted-refresh-token",
|
|
||||||
encryptionType: "electron-safe-storage",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mockFs.existsSync.mockReturnValue(true);
|
|
||||||
mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent));
|
|
||||||
mockSafeStorage.decryptString
|
|
||||||
.mockReturnValueOnce("decrypted-refresh-token")
|
|
||||||
.mockReturnValueOnce("decrypted-access-token");
|
|
||||||
|
|
||||||
const result = readSettings();
|
|
||||||
|
|
||||||
expect(mockSafeStorage.decryptString).toHaveBeenCalledTimes(2);
|
|
||||||
expect(result.supabase?.refreshToken).toEqual({
|
|
||||||
value: "decrypted-refresh-token",
|
|
||||||
encryptionType: "electron-safe-storage",
|
|
||||||
});
|
|
||||||
expect(result.supabase?.accessToken).toEqual({
|
|
||||||
value: "decrypted-access-token",
|
|
||||||
encryptionType: "electron-safe-storage",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle plaintext secrets without decryption", () => {
|
|
||||||
const mockFileContent = {
|
|
||||||
githubAccessToken: {
|
|
||||||
value: "plaintext-token",
|
|
||||||
encryptionType: "plaintext",
|
|
||||||
},
|
|
||||||
providerSettings: {
|
|
||||||
openai: {
|
|
||||||
apiKey: {
|
|
||||||
value: "plaintext-api-key",
|
|
||||||
encryptionType: "plaintext",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mockFs.existsSync.mockReturnValue(true);
|
|
||||||
mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent));
|
|
||||||
|
|
||||||
const result = readSettings();
|
|
||||||
|
|
||||||
expect(mockSafeStorage.decryptString).not.toHaveBeenCalled();
|
|
||||||
expect(result.githubAccessToken?.value).toBe("plaintext-token");
|
|
||||||
expect(result.providerSettings.openai.apiKey?.value).toBe(
|
|
||||||
"plaintext-api-key",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle secrets without encryptionType", () => {
|
|
||||||
const mockFileContent = {
|
|
||||||
githubAccessToken: {
|
|
||||||
value: "token-without-encryption-type",
|
|
||||||
},
|
|
||||||
providerSettings: {
|
|
||||||
openai: {
|
|
||||||
apiKey: {
|
|
||||||
value: "api-key-without-encryption-type",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mockFs.existsSync.mockReturnValue(true);
|
|
||||||
mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent));
|
|
||||||
|
|
||||||
const result = readSettings();
|
|
||||||
|
|
||||||
expect(mockSafeStorage.decryptString).not.toHaveBeenCalled();
|
|
||||||
expect(result.githubAccessToken?.value).toBe(
|
|
||||||
"token-without-encryption-type",
|
|
||||||
);
|
|
||||||
expect(result.providerSettings.openai.apiKey?.value).toBe(
|
|
||||||
"api-key-without-encryption-type",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should strip extra fields not recognized by the schema", () => {
|
|
||||||
const mockFileContent = {
|
|
||||||
selectedModel: {
|
|
||||||
name: "gpt-4",
|
|
||||||
provider: "openai",
|
|
||||||
},
|
|
||||||
telemetryConsent: "opted_in",
|
|
||||||
hasRunBefore: true,
|
|
||||||
// Extra fields that are not in the schema
|
|
||||||
unknownField: "should be removed",
|
|
||||||
deprecatedSetting: true,
|
|
||||||
extraConfig: {
|
|
||||||
someValue: 123,
|
|
||||||
anotherValue: "test",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mockFs.existsSync.mockReturnValue(true);
|
|
||||||
mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent));
|
|
||||||
|
|
||||||
const result = readSettings();
|
|
||||||
|
|
||||||
expect(mockFs.readFileSync).toHaveBeenCalledWith(
|
|
||||||
mockSettingsPath,
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
expect(result.selectedModel).toEqual({
|
|
||||||
name: "gpt-4",
|
|
||||||
provider: "openai",
|
|
||||||
});
|
|
||||||
expect(result.telemetryConsent).toBe("opted_in");
|
|
||||||
expect(result.hasRunBefore).toBe(true);
|
|
||||||
|
|
||||||
// Extra fields should be stripped by schema validation
|
|
||||||
expect(result).not.toHaveProperty("unknownField");
|
|
||||||
expect(result).not.toHaveProperty("deprecatedSetting");
|
|
||||||
expect(result).not.toHaveProperty("extraConfig");
|
|
||||||
|
|
||||||
// Should still have defaults for missing properties
|
|
||||||
expect(result.enableAutoUpdate).toBe(true);
|
|
||||||
expect(result.releaseChannel).toBe("stable");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("error handling", () => {
|
|
||||||
it("should return default settings when file read fails", () => {
|
|
||||||
mockFs.existsSync.mockReturnValue(true);
|
|
||||||
mockFs.readFileSync.mockImplementation(() => {
|
|
||||||
throw new Error("File read error");
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = readSettings();
|
|
||||||
|
|
||||||
expect(scrubSettings(result)).toMatchInlineSnapshot(`
|
|
||||||
{
|
|
||||||
"enableAutoFixProblems": false,
|
|
||||||
"enableAutoUpdate": true,
|
|
||||||
"enableProLazyEditsMode": true,
|
|
||||||
"enableProSmartFilesContextMode": true,
|
|
||||||
"experiments": {},
|
|
||||||
"hasRunBefore": false,
|
|
||||||
"isRunning": false,
|
|
||||||
"lastKnownPerformance": undefined,
|
|
||||||
"providerSettings": {},
|
|
||||||
"releaseChannel": "stable",
|
|
||||||
"selectedChatMode": "build",
|
|
||||||
"selectedModel": {
|
|
||||||
"name": "auto",
|
|
||||||
"provider": "auto",
|
|
||||||
},
|
|
||||||
"selectedTemplateId": "react",
|
|
||||||
"telemetryConsent": "unset",
|
|
||||||
"telemetryUserId": "[scrubbed]",
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return default settings when JSON parsing fails", () => {
|
|
||||||
mockFs.existsSync.mockReturnValue(true);
|
|
||||||
mockFs.readFileSync.mockReturnValue("invalid json");
|
|
||||||
|
|
||||||
const result = readSettings();
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
selectedModel: {
|
|
||||||
name: "auto",
|
|
||||||
provider: "auto",
|
|
||||||
},
|
|
||||||
releaseChannel: "stable",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return default settings when schema validation fails", () => {
|
|
||||||
const mockFileContent = {
|
|
||||||
selectedModel: {
|
|
||||||
name: "gpt-4",
|
|
||||||
// Missing required 'provider' field
|
|
||||||
},
|
|
||||||
releaseChannel: "invalid-channel", // Invalid enum value
|
|
||||||
};
|
|
||||||
|
|
||||||
mockFs.existsSync.mockReturnValue(true);
|
|
||||||
mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent));
|
|
||||||
|
|
||||||
const result = readSettings();
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
selectedModel: {
|
|
||||||
name: "auto",
|
|
||||||
provider: "auto",
|
|
||||||
},
|
|
||||||
releaseChannel: "stable",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle decryption errors gracefully", () => {
|
|
||||||
const mockFileContent = {
|
|
||||||
githubAccessToken: {
|
|
||||||
value: "corrupted-encrypted-data",
|
|
||||||
encryptionType: "electron-safe-storage",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mockFs.existsSync.mockReturnValue(true);
|
|
||||||
mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent));
|
|
||||||
mockSafeStorage.decryptString.mockImplementation(() => {
|
|
||||||
throw new Error("Decryption failed");
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = readSettings();
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
selectedModel: {
|
|
||||||
name: "auto",
|
|
||||||
provider: "auto",
|
|
||||||
},
|
|
||||||
releaseChannel: "stable",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getSettingsFilePath", () => {
|
|
||||||
it("should return correct settings file path", () => {
|
|
||||||
const result = getSettingsFilePath();
|
|
||||||
|
|
||||||
expect(mockGetUserDataPath).toHaveBeenCalled();
|
|
||||||
expect(mockPath.join).toHaveBeenCalledWith(
|
|
||||||
mockUserDataPath,
|
|
||||||
"user-settings.json",
|
|
||||||
);
|
|
||||||
expect(result).toBe(mockSettingsPath);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function scrubSettings(result: UserSettings) {
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
telemetryUserId: "[scrubbed]",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { replacePromptReference } from "@/ipc/utils/replacePromptReference";
|
|
||||||
|
|
||||||
describe("replacePromptReference", () => {
|
|
||||||
it("returns original when no references present", () => {
|
|
||||||
const input = "Hello world";
|
|
||||||
const output = replacePromptReference(input, {});
|
|
||||||
expect(output).toBe(input);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("replaces a single @prompt:id with content", () => {
|
|
||||||
const input = "Use this: @prompt:42";
|
|
||||||
const prompts = { 42: "Meaning of life" };
|
|
||||||
const output = replacePromptReference(input, prompts);
|
|
||||||
expect(output).toBe("Use this: Meaning of life");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("replaces multiple occurrences and keeps surrounding text", () => {
|
|
||||||
const input = "A @prompt:1 and B @prompt:2 end";
|
|
||||||
const prompts = { 1: "One", 2: "Two" };
|
|
||||||
const output = replacePromptReference(input, prompts);
|
|
||||||
expect(output).toBe("A One and B Two end");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves unknown references intact", () => {
|
|
||||||
const input = "Unknown @prompt:99 here";
|
|
||||||
const prompts = { 1: "One" };
|
|
||||||
const output = replacePromptReference(input, prompts);
|
|
||||||
expect(output).toBe("Unknown @prompt:99 here");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports string keys in map as well as numeric", () => {
|
|
||||||
const input = "Mix @prompt:7 and @prompt:8";
|
|
||||||
const prompts = { "7": "Seven", 8: "Eight" } as Record<
|
|
||||||
string | number,
|
|
||||||
string
|
|
||||||
>;
|
|
||||||
const output = replacePromptReference(input, prompts);
|
|
||||||
expect(output).toBe("Mix Seven and Eight");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { stylesToTailwind } from "../utils/style-utils";
|
|
||||||
|
|
||||||
describe("convertSpacingToTailwind", () => {
|
|
||||||
describe("margin conversion", () => {
|
|
||||||
it("should convert equal margins on all sides", () => {
|
|
||||||
const result = stylesToTailwind({
|
|
||||||
margin: { left: "16px", right: "16px", top: "16px", bottom: "16px" },
|
|
||||||
});
|
|
||||||
expect(result).toEqual(["m-[16px]"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should convert equal horizontal margins", () => {
|
|
||||||
const result = stylesToTailwind({
|
|
||||||
margin: { left: "16px", right: "16px" },
|
|
||||||
});
|
|
||||||
expect(result).toEqual(["mx-[16px]"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should convert equal vertical margins", () => {
|
|
||||||
const result = stylesToTailwind({
|
|
||||||
margin: { top: "16px", bottom: "16px" },
|
|
||||||
});
|
|
||||||
expect(result).toEqual(["my-[16px]"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("padding conversion", () => {
|
|
||||||
it("should convert equal padding on all sides", () => {
|
|
||||||
const result = stylesToTailwind({
|
|
||||||
padding: { left: "20px", right: "20px", top: "20px", bottom: "20px" },
|
|
||||||
});
|
|
||||||
expect(result).toEqual(["p-[20px]"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should convert equal horizontal padding", () => {
|
|
||||||
const result = stylesToTailwind({
|
|
||||||
padding: { left: "12px", right: "12px" },
|
|
||||||
});
|
|
||||||
expect(result).toEqual(["px-[12px]"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should convert equal vertical padding", () => {
|
|
||||||
const result = stylesToTailwind({
|
|
||||||
padding: { top: "8px", bottom: "8px" },
|
|
||||||
});
|
|
||||||
expect(result).toEqual(["py-[8px]"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("combined margin and padding", () => {
|
|
||||||
it("should handle both margin and padding", () => {
|
|
||||||
const result = stylesToTailwind({
|
|
||||||
margin: { left: "16px", right: "16px" },
|
|
||||||
padding: { top: "8px", bottom: "8px" },
|
|
||||||
});
|
|
||||||
expect(result).toContain("mx-[16px]");
|
|
||||||
expect(result).toContain("py-[8px]");
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("edge cases: equal horizontal and vertical spacing", () => {
|
|
||||||
it("should consolidate px = py to p when values match", () => {
|
|
||||||
const result = stylesToTailwind({
|
|
||||||
padding: { left: "16px", right: "16px", top: "16px", bottom: "16px" },
|
|
||||||
});
|
|
||||||
// When all four sides are equal, should use p-[]
|
|
||||||
expect(result).toEqual(["p-[16px]"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should consolidate mx = my to m when values match (but not all four sides)", () => {
|
|
||||||
const result = stylesToTailwind({
|
|
||||||
margin: { left: "20px", right: "20px", top: "20px", bottom: "20px" },
|
|
||||||
});
|
|
||||||
// When all four sides are equal, should use m-[]
|
|
||||||
expect(result).toEqual(["m-[20px]"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not consolidate when px != py", () => {
|
|
||||||
const result = stylesToTailwind({
|
|
||||||
padding: { left: "16px", right: "16px", top: "8px", bottom: "8px" },
|
|
||||||
});
|
|
||||||
expect(result).toContain("px-[16px]");
|
|
||||||
expect(result).toContain("py-[8px]");
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not consolidate when mx != my", () => {
|
|
||||||
const result = stylesToTailwind({
|
|
||||||
margin: { left: "20px", right: "20px", top: "10px", bottom: "10px" },
|
|
||||||
});
|
|
||||||
expect(result).toContain("mx-[20px]");
|
|
||||||
expect(result).toContain("my-[10px]");
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle case where left != right", () => {
|
|
||||||
const result = stylesToTailwind({
|
|
||||||
padding: { left: "16px", right: "12px", top: "8px", bottom: "8px" },
|
|
||||||
});
|
|
||||||
expect(result).toContain("pl-[16px]");
|
|
||||||
expect(result).toContain("pr-[12px]");
|
|
||||||
expect(result).toContain("py-[8px]");
|
|
||||||
expect(result).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle case where top != bottom", () => {
|
|
||||||
const result = stylesToTailwind({
|
|
||||||
margin: { left: "20px", right: "20px", top: "10px", bottom: "15px" },
|
|
||||||
});
|
|
||||||
expect(result).toContain("mx-[20px]");
|
|
||||||
expect(result).toContain("mt-[10px]");
|
|
||||||
expect(result).toContain("mb-[15px]");
|
|
||||||
expect(result).toHaveLength(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import {
|
|
||||||
isServerFunction,
|
|
||||||
isSharedServerModule,
|
|
||||||
extractFunctionNameFromPath,
|
|
||||||
} from "@/supabase_admin/supabase_utils";
|
|
||||||
import {
|
|
||||||
toPosixPath,
|
|
||||||
stripSupabaseFunctionsPrefix,
|
|
||||||
buildSignature,
|
|
||||||
type FileStatEntry,
|
|
||||||
} from "@/supabase_admin/supabase_management_client";
|
|
||||||
|
|
||||||
describe("isServerFunction", () => {
|
|
||||||
describe("returns true for valid function paths", () => {
|
|
||||||
it("should return true for function index.ts", () => {
|
|
||||||
expect(isServerFunction("supabase/functions/hello/index.ts")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return true for nested function files", () => {
|
|
||||||
expect(isServerFunction("supabase/functions/hello/lib/utils.ts")).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return true for function with complex name", () => {
|
|
||||||
expect(isServerFunction("supabase/functions/send-email/index.ts")).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("returns false for non-function paths", () => {
|
|
||||||
it("should return false for shared modules", () => {
|
|
||||||
expect(isServerFunction("supabase/functions/_shared/utils.ts")).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false for regular source files", () => {
|
|
||||||
expect(isServerFunction("src/components/Button.tsx")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false for root supabase files", () => {
|
|
||||||
expect(isServerFunction("supabase/config.toml")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false for non-supabase paths", () => {
|
|
||||||
expect(isServerFunction("package.json")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isSharedServerModule", () => {
|
|
||||||
describe("returns true for _shared paths", () => {
|
|
||||||
it("should return true for files in _shared", () => {
|
|
||||||
expect(isSharedServerModule("supabase/functions/_shared/utils.ts")).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return true for nested _shared files", () => {
|
|
||||||
expect(
|
|
||||||
isSharedServerModule("supabase/functions/_shared/lib/helpers.ts"),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return true for _shared directory itself", () => {
|
|
||||||
expect(isSharedServerModule("supabase/functions/_shared/")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("returns false for non-_shared paths", () => {
|
|
||||||
it("should return false for regular functions", () => {
|
|
||||||
expect(isSharedServerModule("supabase/functions/hello/index.ts")).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false for similar but different paths", () => {
|
|
||||||
expect(isSharedServerModule("supabase/functions/shared/utils.ts")).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false for _shared in wrong location", () => {
|
|
||||||
expect(isSharedServerModule("src/_shared/utils.ts")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("extractFunctionNameFromPath", () => {
|
|
||||||
describe("extracts function name correctly from nested paths", () => {
|
|
||||||
it("should extract function name from index.ts path", () => {
|
|
||||||
expect(
|
|
||||||
extractFunctionNameFromPath("supabase/functions/hello/index.ts"),
|
|
||||||
).toBe("hello");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should extract function name from deeply nested path", () => {
|
|
||||||
expect(
|
|
||||||
extractFunctionNameFromPath("supabase/functions/hello/lib/utils.ts"),
|
|
||||||
).toBe("hello");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should extract function name from very deeply nested path", () => {
|
|
||||||
expect(
|
|
||||||
extractFunctionNameFromPath(
|
|
||||||
"supabase/functions/hello/src/helpers/format.ts",
|
|
||||||
),
|
|
||||||
).toBe("hello");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should extract function name with dashes", () => {
|
|
||||||
expect(
|
|
||||||
extractFunctionNameFromPath("supabase/functions/send-email/index.ts"),
|
|
||||||
).toBe("send-email");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should extract function name with underscores", () => {
|
|
||||||
expect(
|
|
||||||
extractFunctionNameFromPath("supabase/functions/my_function/index.ts"),
|
|
||||||
).toBe("my_function");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("throws for invalid paths", () => {
|
|
||||||
it("should throw for _shared paths", () => {
|
|
||||||
expect(() =>
|
|
||||||
extractFunctionNameFromPath("supabase/functions/_shared/utils.ts"),
|
|
||||||
).toThrow(/Function names starting with "_" are reserved/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw for other _ prefixed directories", () => {
|
|
||||||
expect(() =>
|
|
||||||
extractFunctionNameFromPath("supabase/functions/_internal/utils.ts"),
|
|
||||||
).toThrow(/Function names starting with "_" are reserved/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw for non-supabase paths", () => {
|
|
||||||
expect(() =>
|
|
||||||
extractFunctionNameFromPath("src/components/Button.tsx"),
|
|
||||||
).toThrow(/Invalid Supabase function path/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw for supabase root files", () => {
|
|
||||||
expect(() => extractFunctionNameFromPath("supabase/config.toml")).toThrow(
|
|
||||||
/Invalid Supabase function path/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw for partial matches", () => {
|
|
||||||
expect(() => extractFunctionNameFromPath("supabase/functions")).toThrow(
|
|
||||||
/Invalid Supabase function path/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("handles edge cases", () => {
|
|
||||||
it("should handle backslashes (Windows paths)", () => {
|
|
||||||
expect(
|
|
||||||
extractFunctionNameFromPath(
|
|
||||||
"supabase\\functions\\hello\\lib\\utils.ts",
|
|
||||||
),
|
|
||||||
).toBe("hello");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle mixed slashes", () => {
|
|
||||||
expect(
|
|
||||||
extractFunctionNameFromPath("supabase/functions\\hello/lib\\utils.ts"),
|
|
||||||
).toBe("hello");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("toPosixPath", () => {
|
|
||||||
it("should keep forward slashes unchanged", () => {
|
|
||||||
expect(toPosixPath("supabase/functions/hello/index.ts")).toBe(
|
|
||||||
"supabase/functions/hello/index.ts",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle empty string", () => {
|
|
||||||
expect(toPosixPath("")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle single filename", () => {
|
|
||||||
expect(toPosixPath("index.ts")).toBe("index.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Note: On Unix, path.sep is "/", so backslashes won't be converted
|
|
||||||
// This test is for documentation - actual behavior depends on platform
|
|
||||||
it("should handle path with no separators", () => {
|
|
||||||
expect(toPosixPath("filename")).toBe("filename");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("stripSupabaseFunctionsPrefix", () => {
|
|
||||||
describe("strips prefix correctly", () => {
|
|
||||||
it("should strip full prefix from index.ts", () => {
|
|
||||||
expect(
|
|
||||||
stripSupabaseFunctionsPrefix(
|
|
||||||
"supabase/functions/hello/index.ts",
|
|
||||||
"hello",
|
|
||||||
),
|
|
||||||
).toBe("index.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should strip prefix from nested file", () => {
|
|
||||||
expect(
|
|
||||||
stripSupabaseFunctionsPrefix(
|
|
||||||
"supabase/functions/hello/lib/utils.ts",
|
|
||||||
"hello",
|
|
||||||
),
|
|
||||||
).toBe("lib/utils.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle leading slash", () => {
|
|
||||||
expect(
|
|
||||||
stripSupabaseFunctionsPrefix(
|
|
||||||
"/supabase/functions/hello/index.ts",
|
|
||||||
"hello",
|
|
||||||
),
|
|
||||||
).toBe("index.ts");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("handles edge cases", () => {
|
|
||||||
it("should return filename when no prefix match", () => {
|
|
||||||
const result = stripSupabaseFunctionsPrefix("just-a-file.ts", "hello");
|
|
||||||
expect(result).toBe("just-a-file.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle paths without function name", () => {
|
|
||||||
const result = stripSupabaseFunctionsPrefix(
|
|
||||||
"supabase/functions/other/index.ts",
|
|
||||||
"hello",
|
|
||||||
);
|
|
||||||
// Should strip base prefix and return the rest
|
|
||||||
expect(result).toBe("other/index.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle empty relative path after prefix", () => {
|
|
||||||
// When the path is exactly the function directory
|
|
||||||
const result = stripSupabaseFunctionsPrefix(
|
|
||||||
"supabase/functions/hello",
|
|
||||||
"hello",
|
|
||||||
);
|
|
||||||
expect(result).toBe("hello");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buildSignature", () => {
|
|
||||||
it("should build signature from single entry", () => {
|
|
||||||
const entries: FileStatEntry[] = [
|
|
||||||
{
|
|
||||||
absolutePath: "/app/file.ts",
|
|
||||||
relativePath: "file.ts",
|
|
||||||
mtimeMs: 1000,
|
|
||||||
size: 100,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const result = buildSignature(entries);
|
|
||||||
expect(result).toBe("file.ts:3e8:64");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should build signature from multiple entries sorted by relativePath", () => {
|
|
||||||
const entries: FileStatEntry[] = [
|
|
||||||
{
|
|
||||||
absolutePath: "/app/b.ts",
|
|
||||||
relativePath: "b.ts",
|
|
||||||
mtimeMs: 2000,
|
|
||||||
size: 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
absolutePath: "/app/a.ts",
|
|
||||||
relativePath: "a.ts",
|
|
||||||
mtimeMs: 1000,
|
|
||||||
size: 100,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const result = buildSignature(entries);
|
|
||||||
// Should be sorted by relativePath
|
|
||||||
expect(result).toBe("a.ts:3e8:64|b.ts:7d0:c8");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return empty string for empty array", () => {
|
|
||||||
const result = buildSignature([]);
|
|
||||||
expect(result).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should produce different signatures for different mtimes", () => {
|
|
||||||
const entries1: FileStatEntry[] = [
|
|
||||||
{
|
|
||||||
absolutePath: "/app/file.ts",
|
|
||||||
relativePath: "file.ts",
|
|
||||||
mtimeMs: 1000,
|
|
||||||
size: 100,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const entries2: FileStatEntry[] = [
|
|
||||||
{
|
|
||||||
absolutePath: "/app/file.ts",
|
|
||||||
relativePath: "file.ts",
|
|
||||||
mtimeMs: 2000,
|
|
||||||
size: 100,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
expect(buildSignature(entries1)).not.toBe(buildSignature(entries2));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should produce different signatures for different sizes", () => {
|
|
||||||
const entries1: FileStatEntry[] = [
|
|
||||||
{
|
|
||||||
absolutePath: "/app/file.ts",
|
|
||||||
relativePath: "file.ts",
|
|
||||||
mtimeMs: 1000,
|
|
||||||
size: 100,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const entries2: FileStatEntry[] = [
|
|
||||||
{
|
|
||||||
absolutePath: "/app/file.ts",
|
|
||||||
relativePath: "file.ts",
|
|
||||||
mtimeMs: 1000,
|
|
||||||
size: 200,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
expect(buildSignature(entries1)).not.toBe(buildSignature(entries2));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should include path in signature for cache invalidation", () => {
|
|
||||||
const entries1: FileStatEntry[] = [
|
|
||||||
{
|
|
||||||
absolutePath: "/app/a.ts",
|
|
||||||
relativePath: "a.ts",
|
|
||||||
mtimeMs: 1000,
|
|
||||||
size: 100,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const entries2: FileStatEntry[] = [
|
|
||||||
{
|
|
||||||
absolutePath: "/app/b.ts",
|
|
||||||
relativePath: "b.ts",
|
|
||||||
mtimeMs: 1000,
|
|
||||||
size: 100,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
expect(buildSignature(entries1)).not.toBe(buildSignature(entries2));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,244 +0,0 @@
|
|||||||
import { useAtom } from "jotai";
|
|
||||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
|
||||||
import { useLoadApps } from "@/hooks/useLoadApps";
|
|
||||||
import { useRouter, useLocation } from "@tanstack/react-router";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
// @ts-ignore
|
|
||||||
import logo from "../../assets/logo.svg";
|
|
||||||
import { providerSettingsRoute } from "@/routes/settings/providers/$provider";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useDeepLink } from "@/contexts/DeepLinkContext";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { DyadProSuccessDialog } from "@/components/DyadProSuccessDialog";
|
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
|
|
||||||
import { UserBudgetInfo } from "@/ipc/ipc_types";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { ActionHeader } from "@/components/preview_panel/ActionHeader";
|
|
||||||
|
|
||||||
export const TitleBar = () => {
|
|
||||||
const [selectedAppId] = useAtom(selectedAppIdAtom);
|
|
||||||
const { apps } = useLoadApps();
|
|
||||||
const { navigate } = useRouter();
|
|
||||||
const location = useLocation();
|
|
||||||
const { settings, refreshSettings } = useSettings();
|
|
||||||
const [isSuccessDialogOpen, setIsSuccessDialogOpen] = useState(false);
|
|
||||||
const [showWindowControls, setShowWindowControls] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Check if we're running on Windows
|
|
||||||
const checkPlatform = async () => {
|
|
||||||
try {
|
|
||||||
const platform = await IpcClient.getInstance().getSystemPlatform();
|
|
||||||
setShowWindowControls(platform !== "darwin");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to get platform info:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
checkPlatform();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const showDyadProSuccessDialog = () => {
|
|
||||||
setIsSuccessDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const { lastDeepLink, clearLastDeepLink } = useDeepLink();
|
|
||||||
useEffect(() => {
|
|
||||||
const handleDeepLink = async () => {
|
|
||||||
if (lastDeepLink?.type === "dyad-pro-return") {
|
|
||||||
await refreshSettings();
|
|
||||||
showDyadProSuccessDialog();
|
|
||||||
clearLastDeepLink();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
handleDeepLink();
|
|
||||||
}, [lastDeepLink?.timestamp]);
|
|
||||||
|
|
||||||
// Get selected app name
|
|
||||||
const selectedApp = apps.find((app) => app.id === selectedAppId);
|
|
||||||
const displayText = selectedApp
|
|
||||||
? `App: ${selectedApp.name}`
|
|
||||||
: "(no app selected)";
|
|
||||||
|
|
||||||
const handleAppClick = () => {
|
|
||||||
if (selectedApp) {
|
|
||||||
navigate({ to: "/app-details", search: { appId: selectedApp.id } });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDyadPro = !!settings?.providerSettings?.auto?.apiKey?.value;
|
|
||||||
const isDyadProEnabled = Boolean(settings?.enableDyadPro);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="@container z-11 w-full h-11 bg-(--sidebar) absolute top-0 left-0 app-region-drag flex items-center">
|
|
||||||
<div className={`${showWindowControls ? "pl-2" : "pl-18"}`}></div>
|
|
||||||
|
|
||||||
<img src={logo} alt="Dyad Logo" className="w-6 h-6 mr-0.5" />
|
|
||||||
<Button
|
|
||||||
data-testid="title-bar-app-name-button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className={`hidden @2xl:block no-app-region-drag text-xs max-w-38 truncate font-medium ${
|
|
||||||
selectedApp ? "cursor-pointer" : ""
|
|
||||||
}`}
|
|
||||||
onClick={handleAppClick}
|
|
||||||
>
|
|
||||||
{displayText}
|
|
||||||
</Button>
|
|
||||||
{isDyadPro && <DyadProButton isDyadProEnabled={isDyadProEnabled} />}
|
|
||||||
|
|
||||||
{/* Preview Header */}
|
|
||||||
{location.pathname === "/chat" && (
|
|
||||||
<div className="flex-1 flex justify-end">
|
|
||||||
<ActionHeader />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showWindowControls && <WindowsControls />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DyadProSuccessDialog
|
|
||||||
isOpen={isSuccessDialogOpen}
|
|
||||||
onClose={() => setIsSuccessDialogOpen(false)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function WindowsControls() {
|
|
||||||
const { isDarkMode } = useTheme();
|
|
||||||
const ipcClient = IpcClient.getInstance();
|
|
||||||
|
|
||||||
const minimizeWindow = () => {
|
|
||||||
ipcClient.minimizeWindow();
|
|
||||||
};
|
|
||||||
|
|
||||||
const maximizeWindow = () => {
|
|
||||||
ipcClient.maximizeWindow();
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeWindow = () => {
|
|
||||||
ipcClient.closeWindow();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="ml-auto flex no-app-region-drag">
|
|
||||||
<button
|
|
||||||
className="w-10 h-10 flex items-center justify-center hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
|
||||||
onClick={minimizeWindow}
|
|
||||||
aria-label="Minimize"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="12"
|
|
||||||
height="1"
|
|
||||||
viewBox="0 0 12 1"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
width="12"
|
|
||||||
height="1"
|
|
||||||
fill={isDarkMode ? "#ffffff" : "#000000"}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="w-10 h-10 flex items-center justify-center hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
|
||||||
onClick={maximizeWindow}
|
|
||||||
aria-label="Maximize"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
viewBox="0 0 12 12"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
x="0.5"
|
|
||||||
y="0.5"
|
|
||||||
width="11"
|
|
||||||
height="11"
|
|
||||||
stroke={isDarkMode ? "#ffffff" : "#000000"}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="w-10 h-10 flex items-center justify-center hover:bg-red-500 transition-colors"
|
|
||||||
onClick={closeWindow}
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
viewBox="0 0 12 12"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M1 1L11 11M1 11L11 1"
|
|
||||||
stroke={isDarkMode ? "#ffffff" : "#000000"}
|
|
||||||
strokeWidth="1.5"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DyadProButton({
|
|
||||||
isDyadProEnabled,
|
|
||||||
}: {
|
|
||||||
isDyadProEnabled: boolean;
|
|
||||||
}) {
|
|
||||||
const { navigate } = useRouter();
|
|
||||||
const { userBudget } = useUserBudgetInfo();
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
data-testid="title-bar-dyad-pro-button"
|
|
||||||
onClick={() => {
|
|
||||||
navigate({
|
|
||||||
to: providerSettingsRoute.id,
|
|
||||||
params: { provider: "auto" },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"hidden @2xl:block ml-1 no-app-region-drag h-7 bg-indigo-600 text-white dark:bg-indigo-600 dark:text-white text-xs px-2 pt-1 pb-1",
|
|
||||||
!isDyadProEnabled && "bg-zinc-600 dark:bg-zinc-600",
|
|
||||||
)}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{isDyadProEnabled ? "Pro" : "Pro (off)"}
|
|
||||||
{userBudget && isDyadProEnabled && (
|
|
||||||
<AICreditStatus userBudget={userBudget} />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AICreditStatus({ userBudget }: { userBudget: UserBudgetInfo }) {
|
|
||||||
const remaining = Math.round(
|
|
||||||
userBudget.totalCredits - userBudget.usedCredits,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<div className="text-xs pl-1 mt-0.5">{remaining} credits</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<div>
|
|
||||||
<p>Note: there is a slight delay in updating the credit status.</p>
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
|
||||||
import { AppSidebar } from "@/components/app-sidebar";
|
|
||||||
import { ThemeProvider } from "../contexts/ThemeContext";
|
|
||||||
import { DeepLinkProvider } from "../contexts/DeepLinkContext";
|
|
||||||
import { Toaster } from "sonner";
|
|
||||||
import { TitleBar } from "./TitleBar";
|
|
||||||
import { useEffect, type ReactNode } from "react";
|
|
||||||
import { useRunApp } from "@/hooks/useRunApp";
|
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
|
||||||
import { previewModeAtom, selectedAppIdAtom } from "@/atoms/appAtoms";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import type { ZoomLevel } from "@/lib/schemas";
|
|
||||||
import { selectedComponentsPreviewAtom } from "@/atoms/previewAtoms";
|
|
||||||
import { chatInputValueAtom } from "@/atoms/chatAtoms";
|
|
||||||
|
|
||||||
const DEFAULT_ZOOM_LEVEL: ZoomLevel = "100";
|
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
||||||
const { refreshAppIframe } = useRunApp();
|
|
||||||
const previewMode = useAtomValue(previewModeAtom);
|
|
||||||
const { settings } = useSettings();
|
|
||||||
const setSelectedComponentsPreview = useSetAtom(
|
|
||||||
selectedComponentsPreviewAtom,
|
|
||||||
);
|
|
||||||
const setChatInput = useSetAtom(chatInputValueAtom);
|
|
||||||
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const zoomLevel = settings?.zoomLevel ?? DEFAULT_ZOOM_LEVEL;
|
|
||||||
const zoomFactor = Number(zoomLevel) / 100;
|
|
||||||
|
|
||||||
const electronApi = (
|
|
||||||
window as Window & {
|
|
||||||
electron?: {
|
|
||||||
webFrame?: {
|
|
||||||
setZoomFactor: (factor: number) => void;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
).electron;
|
|
||||||
|
|
||||||
if (electronApi?.webFrame?.setZoomFactor) {
|
|
||||||
electronApi.webFrame.setZoomFactor(zoomFactor);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
electronApi.webFrame?.setZoomFactor(Number(DEFAULT_ZOOM_LEVEL) / 100);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {};
|
|
||||||
}, [settings?.zoomLevel]);
|
|
||||||
// Global keyboard listener for refresh events
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
// Check for Ctrl+R (Windows/Linux) or Cmd+R (macOS)
|
|
||||||
if (event.key === "r" && (event.ctrlKey || event.metaKey)) {
|
|
||||||
event.preventDefault(); // Prevent default browser refresh
|
|
||||||
if (previewMode === "preview") {
|
|
||||||
refreshAppIframe(); // Use our custom refresh function instead
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add event listener to document
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
|
|
||||||
// Cleanup function to remove event listener
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [refreshAppIframe, previewMode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setChatInput("");
|
|
||||||
setSelectedComponentsPreview([]);
|
|
||||||
}, [selectedAppId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ThemeProvider>
|
|
||||||
<DeepLinkProvider>
|
|
||||||
<SidebarProvider>
|
|
||||||
<TitleBar />
|
|
||||||
<AppSidebar />
|
|
||||||
<div
|
|
||||||
id="layout-main-content-container"
|
|
||||||
className="flex h-screenish w-full overflow-x-hidden mt-12 mb-4 mr-4 border-t border-l border-border rounded-lg bg-background"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
<Toaster richColors />
|
|
||||||
</SidebarProvider>
|
|
||||||
</DeepLinkProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { atom } from "jotai";
|
|
||||||
import type { App, AppOutput, Version } from "@/ipc/ipc_types";
|
|
||||||
import type { UserSettings } from "@/lib/schemas";
|
|
||||||
|
|
||||||
export const currentAppAtom = atom<App | null>(null);
|
|
||||||
export const selectedAppIdAtom = atom<number | null>(null);
|
|
||||||
export const appsListAtom = atom<App[]>([]);
|
|
||||||
export const appBasePathAtom = atom<string>("");
|
|
||||||
export const versionsListAtom = atom<Version[]>([]);
|
|
||||||
export const previewModeAtom = atom<
|
|
||||||
"preview" | "code" | "problems" | "configure" | "publish" | "security"
|
|
||||||
>("preview");
|
|
||||||
export const selectedVersionIdAtom = atom<string | null>(null);
|
|
||||||
export const appOutputAtom = atom<AppOutput[]>([]);
|
|
||||||
export const appUrlAtom = atom<
|
|
||||||
| { appUrl: string; appId: number; originalUrl: string }
|
|
||||||
| { appUrl: null; appId: null; originalUrl: null }
|
|
||||||
>({ appUrl: null, appId: null, originalUrl: null });
|
|
||||||
export const userSettingsAtom = atom<UserSettings | null>(null);
|
|
||||||
|
|
||||||
// Atom for storing allow-listed environment variables
|
|
||||||
export const envVarsAtom = atom<Record<string, string | undefined>>({});
|
|
||||||
|
|
||||||
export const previewPanelKeyAtom = atom<number>(0);
|
|
||||||
|
|
||||||
export const previewErrorMessageAtom = atom<
|
|
||||||
{ message: string; source: "preview-app" | "dyad-app" } | undefined
|
|
||||||
>(undefined);
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { FileAttachment, Message } from "@/ipc/ipc_types";
|
|
||||||
import { atom } from "jotai";
|
|
||||||
import type { ChatSummary } from "@/lib/schemas";
|
|
||||||
|
|
||||||
// Per-chat atoms implemented with maps keyed by chatId
|
|
||||||
export const chatMessagesByIdAtom = atom<Map<number, Message[]>>(new Map());
|
|
||||||
export const chatErrorByIdAtom = atom<Map<number, string | null>>(new Map());
|
|
||||||
|
|
||||||
// Atom to hold the currently selected chat ID
|
|
||||||
export const selectedChatIdAtom = atom<number | null>(null);
|
|
||||||
|
|
||||||
export const isStreamingByIdAtom = atom<Map<number, boolean>>(new Map());
|
|
||||||
export const chatInputValueAtom = atom<string>("");
|
|
||||||
export const homeChatInputValueAtom = atom<string>("");
|
|
||||||
|
|
||||||
// Atoms for chat list management
|
|
||||||
export const chatsAtom = atom<ChatSummary[]>([]);
|
|
||||||
export const chatsLoadingAtom = atom<boolean>(false);
|
|
||||||
|
|
||||||
// Used for scrolling to the bottom of the chat messages (per chat)
|
|
||||||
export const chatStreamCountByIdAtom = atom<Map<number, number>>(new Map());
|
|
||||||
export const recentStreamChatIdsAtom = atom<Set<number>>(new Set<number>());
|
|
||||||
|
|
||||||
export const attachmentsAtom = atom<FileAttachment[]>([]);
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { atom } from "jotai";
|
|
||||||
import { type LocalModel } from "@/ipc/ipc_types";
|
|
||||||
|
|
||||||
export const localModelsAtom = atom<LocalModel[]>([]);
|
|
||||||
export const localModelsLoadingAtom = atom<boolean>(false);
|
|
||||||
export const localModelsErrorAtom = atom<Error | null>(null);
|
|
||||||
|
|
||||||
export const lmStudioModelsAtom = atom<LocalModel[]>([]);
|
|
||||||
export const lmStudioModelsLoadingAtom = atom<boolean>(false);
|
|
||||||
export const lmStudioModelsErrorAtom = atom<Error | null>(null);
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { ComponentSelection, VisualEditingChange } from "@/ipc/ipc_types";
|
|
||||||
import { atom } from "jotai";
|
|
||||||
|
|
||||||
export const selectedComponentsPreviewAtom = atom<ComponentSelection[]>([]);
|
|
||||||
|
|
||||||
export const visualEditingSelectedComponentAtom =
|
|
||||||
atom<ComponentSelection | null>(null);
|
|
||||||
|
|
||||||
export const currentComponentCoordinatesAtom = atom<{
|
|
||||||
top: number;
|
|
||||||
left: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
export const previewIframeRefAtom = atom<HTMLIFrameElement | null>(null);
|
|
||||||
|
|
||||||
export const annotatorModeAtom = atom<boolean>(false);
|
|
||||||
|
|
||||||
export const screenshotDataUrlAtom = atom<string | null>(null);
|
|
||||||
export const pendingVisualChangesAtom = atom<Map<string, VisualEditingChange>>(
|
|
||||||
new Map(),
|
|
||||||
);
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { atom } from "jotai";
|
|
||||||
import type { ProposalResult } from "@/lib/schemas";
|
|
||||||
|
|
||||||
export const proposalResultAtom = atom<ProposalResult | null>(null);
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { atom } from "jotai";
|
|
||||||
import { SupabaseBranch } from "@/ipc/ipc_types";
|
|
||||||
|
|
||||||
// Define atom for storing the list of Supabase projects
|
|
||||||
export const supabaseProjectsAtom = atom<any[]>([]);
|
|
||||||
export const supabaseBranchesAtom = atom<SupabaseBranch[]>([]);
|
|
||||||
|
|
||||||
// Define atom for tracking loading state
|
|
||||||
export const supabaseLoadingAtom = atom<boolean>(false);
|
|
||||||
|
|
||||||
// Define atom for storing any error that occurs during loading
|
|
||||||
export const supabaseErrorAtom = atom<Error | null>(null);
|
|
||||||
|
|
||||||
// Define atom for storing the currently selected Supabase project
|
|
||||||
export const selectedSupabaseProjectAtom = atom<string | null>(null);
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { atom } from "jotai";
|
|
||||||
|
|
||||||
// Atom to track if any dropdown is currently open in the UI
|
|
||||||
export const dropdownOpenAtom = atom<boolean>(false);
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { atom } from "jotai";
|
|
||||||
|
|
||||||
export const isPreviewOpenAtom = atom(true);
|
|
||||||
export const selectedFileAtom = atom<{
|
|
||||||
path: string;
|
|
||||||
} | null>(null);
|
|
||||||
export const activeSettingsSectionAtom = atom<string | null>(
|
|
||||||
"general-settings",
|
|
||||||
);
|
|
||||||
@@ -1,390 +0,0 @@
|
|||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs/promises";
|
|
||||||
import { app } from "electron";
|
|
||||||
import * as crypto from "crypto";
|
|
||||||
import log from "electron-log";
|
|
||||||
import Database from "better-sqlite3";
|
|
||||||
|
|
||||||
const logger = log.scope("backup_manager");
|
|
||||||
|
|
||||||
const MAX_BACKUPS = 3;
|
|
||||||
|
|
||||||
interface BackupManagerOptions {
|
|
||||||
settingsFile: string;
|
|
||||||
dbFile: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BackupMetadata {
|
|
||||||
version: string;
|
|
||||||
timestamp: string;
|
|
||||||
reason: string;
|
|
||||||
files: {
|
|
||||||
settings: boolean;
|
|
||||||
database: boolean;
|
|
||||||
};
|
|
||||||
checksums: {
|
|
||||||
settings: string | null;
|
|
||||||
database: string | null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BackupInfo extends BackupMetadata {
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BackupManager {
|
|
||||||
private readonly maxBackups: number;
|
|
||||||
private readonly settingsFilePath: string;
|
|
||||||
private readonly dbFilePath: string;
|
|
||||||
private userDataPath!: string;
|
|
||||||
private backupBasePath!: string;
|
|
||||||
|
|
||||||
constructor(options: BackupManagerOptions) {
|
|
||||||
this.maxBackups = MAX_BACKUPS;
|
|
||||||
this.settingsFilePath = options.settingsFile;
|
|
||||||
this.dbFilePath = options.dbFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize backup system - call this on app ready
|
|
||||||
*/
|
|
||||||
async initialize(): Promise<void> {
|
|
||||||
logger.info("Initializing backup system...");
|
|
||||||
|
|
||||||
// Set paths after app is ready
|
|
||||||
this.userDataPath = app.getPath("userData");
|
|
||||||
this.backupBasePath = path.join(this.userDataPath, "backups");
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Backup system paths - UserData: ${this.userDataPath}, Backups: ${this.backupBasePath}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if this is a version upgrade
|
|
||||||
const currentVersion = app.getVersion();
|
|
||||||
const lastVersion = await this.getLastRunVersion();
|
|
||||||
|
|
||||||
if (lastVersion === null) {
|
|
||||||
logger.info("No previous version found, skipping backup");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastVersion === currentVersion) {
|
|
||||||
logger.info(
|
|
||||||
`No version upgrade detected. Current version: ${currentVersion}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure backup directory exists
|
|
||||||
await fs.mkdir(this.backupBasePath, { recursive: true });
|
|
||||||
logger.debug("Backup directory created/verified");
|
|
||||||
|
|
||||||
logger.info(`Version upgrade detected: ${lastVersion} → ${currentVersion}`);
|
|
||||||
await this.createBackup(`upgrade_from_${lastVersion}`);
|
|
||||||
|
|
||||||
// Save current version
|
|
||||||
await this.saveCurrentVersion(currentVersion);
|
|
||||||
|
|
||||||
// Clean up old backups
|
|
||||||
await this.cleanupOldBackups();
|
|
||||||
logger.info("Backup system initialized successfully");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a backup of settings and database
|
|
||||||
*/
|
|
||||||
async createBackup(reason: string = "manual"): Promise<string> {
|
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
||||||
const version = app.getVersion();
|
|
||||||
const backupName = `v${version}_${timestamp}_${reason}`;
|
|
||||||
const backupPath = path.join(this.backupBasePath, backupName);
|
|
||||||
|
|
||||||
logger.info(`Creating backup: ${backupName} (reason: ${reason})`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create backup directory
|
|
||||||
await fs.mkdir(backupPath, { recursive: true });
|
|
||||||
logger.debug(`Backup directory created: ${backupPath}`);
|
|
||||||
|
|
||||||
// Backup settings file
|
|
||||||
const settingsBackupPath = path.join(
|
|
||||||
backupPath,
|
|
||||||
path.basename(this.settingsFilePath),
|
|
||||||
);
|
|
||||||
const settingsExists = await this.fileExists(this.settingsFilePath);
|
|
||||||
|
|
||||||
if (settingsExists) {
|
|
||||||
await fs.copyFile(this.settingsFilePath, settingsBackupPath);
|
|
||||||
logger.info("Settings backed up successfully");
|
|
||||||
} else {
|
|
||||||
logger.debug("Settings file not found, skipping settings backup");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backup SQLite database
|
|
||||||
const dbBackupPath = path.join(
|
|
||||||
backupPath,
|
|
||||||
path.basename(this.dbFilePath),
|
|
||||||
);
|
|
||||||
const dbExists = await this.fileExists(this.dbFilePath);
|
|
||||||
|
|
||||||
if (dbExists) {
|
|
||||||
await this.backupSQLiteDatabase(this.dbFilePath, dbBackupPath);
|
|
||||||
logger.info("Database backed up successfully");
|
|
||||||
} else {
|
|
||||||
logger.debug("Database file not found, skipping database backup");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create backup metadata
|
|
||||||
const metadata: BackupMetadata = {
|
|
||||||
version,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
reason,
|
|
||||||
files: {
|
|
||||||
settings: settingsExists,
|
|
||||||
database: dbExists,
|
|
||||||
},
|
|
||||||
checksums: {
|
|
||||||
settings: settingsExists
|
|
||||||
? await this.getFileChecksum(settingsBackupPath)
|
|
||||||
: null,
|
|
||||||
database: dbExists ? await this.getFileChecksum(dbBackupPath) : null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(backupPath, "backup.json"),
|
|
||||||
JSON.stringify(metadata, null, 2),
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info(`Backup created successfully: ${backupName}`);
|
|
||||||
return backupPath;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Backup failed:", error);
|
|
||||||
// Clean up failed backup
|
|
||||||
try {
|
|
||||||
await fs.rm(backupPath, { recursive: true, force: true });
|
|
||||||
logger.debug("Failed backup directory cleaned up");
|
|
||||||
} catch (cleanupError) {
|
|
||||||
logger.error("Failed to clean up backup directory:", cleanupError);
|
|
||||||
}
|
|
||||||
throw new Error(`Backup creation failed: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List all available backups
|
|
||||||
*/
|
|
||||||
async listBackups(): Promise<BackupInfo[]> {
|
|
||||||
try {
|
|
||||||
const entries = await fs.readdir(this.backupBasePath, {
|
|
||||||
withFileTypes: true,
|
|
||||||
});
|
|
||||||
const backups: BackupInfo[] = [];
|
|
||||||
|
|
||||||
logger.debug(`Found ${entries.length} entries in backup directory`);
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
const metadataPath = path.join(
|
|
||||||
this.backupBasePath,
|
|
||||||
entry.name,
|
|
||||||
"backup.json",
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const metadataContent = await fs.readFile(metadataPath, "utf8");
|
|
||||||
const metadata: BackupMetadata = JSON.parse(metadataContent);
|
|
||||||
backups.push({
|
|
||||||
name: entry.name,
|
|
||||||
...metadata,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(`Invalid backup found: ${entry.name}`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Found ${backups.length} valid backups`);
|
|
||||||
|
|
||||||
// Sort by timestamp, newest first
|
|
||||||
return backups.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Failed to list backups:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up old backups, keeping only the most recent ones
|
|
||||||
*/
|
|
||||||
async cleanupOldBackups(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const backups = await this.listBackups();
|
|
||||||
|
|
||||||
if (backups.length <= this.maxBackups) {
|
|
||||||
logger.debug(
|
|
||||||
`No cleanup needed - ${backups.length} backups (max: ${this.maxBackups})`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the newest backups
|
|
||||||
const backupsToDelete = backups.slice(this.maxBackups);
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Cleaning up ${backupsToDelete.length} old backups (keeping ${this.maxBackups} most recent)`,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const backup of backupsToDelete) {
|
|
||||||
const backupPath = path.join(this.backupBasePath, backup.name);
|
|
||||||
await fs.rm(backupPath, { recursive: true, force: true });
|
|
||||||
logger.debug(`Deleted old backup: ${backup.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Old backup cleanup completed");
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Failed to clean up old backups:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a specific backup
|
|
||||||
*/
|
|
||||||
async deleteBackup(backupName: string): Promise<void> {
|
|
||||||
const backupPath = path.join(this.backupBasePath, backupName);
|
|
||||||
|
|
||||||
logger.info(`Deleting backup: ${backupName}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fs.rm(backupPath, { recursive: true, force: true });
|
|
||||||
logger.info(`Deleted backup: ${backupName}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to delete backup ${backupName}:`, error);
|
|
||||||
throw new Error(`Failed to delete backup: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get backup size in bytes
|
|
||||||
*/
|
|
||||||
async getBackupSize(backupName: string): Promise<number> {
|
|
||||||
const backupPath = path.join(this.backupBasePath, backupName);
|
|
||||||
logger.debug(`Calculating size for backup: ${backupName}`);
|
|
||||||
|
|
||||||
const size = await this.getDirectorySize(backupPath);
|
|
||||||
logger.debug(`Backup ${backupName} size: ${size} bytes`);
|
|
||||||
|
|
||||||
return size;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Backup SQLite database safely
|
|
||||||
*/
|
|
||||||
private async backupSQLiteDatabase(
|
|
||||||
sourcePath: string,
|
|
||||||
destPath: string,
|
|
||||||
): Promise<void> {
|
|
||||||
logger.debug(`Backing up SQLite database: ${sourcePath} → ${destPath}`);
|
|
||||||
const sourceDb = new Database(sourcePath, {
|
|
||||||
readonly: true,
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// This is safe even if other connections are active
|
|
||||||
await sourceDb.backup(destPath);
|
|
||||||
logger.info("Database backup completed successfully");
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Database backup failed:", error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
// Always close the temporary connection
|
|
||||||
sourceDb.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: Check if file exists
|
|
||||||
*/
|
|
||||||
private async fileExists(filePath: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await fs.access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: Calculate file checksum
|
|
||||||
*/
|
|
||||||
private async getFileChecksum(filePath: string): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const fileBuffer = await fs.readFile(filePath);
|
|
||||||
const hash = crypto.createHash("sha256");
|
|
||||||
hash.update(fileBuffer);
|
|
||||||
const checksum = hash.digest("hex");
|
|
||||||
logger.debug(
|
|
||||||
`Checksum calculated for ${filePath}: ${checksum.substring(0, 8)}...`,
|
|
||||||
);
|
|
||||||
return checksum;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to calculate checksum for ${filePath}:`, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: Get directory size recursively
|
|
||||||
*/
|
|
||||||
private async getDirectorySize(dirPath: string): Promise<number> {
|
|
||||||
let size = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(dirPath, entry.name);
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
size += await this.getDirectorySize(fullPath);
|
|
||||||
} else {
|
|
||||||
const stats = await fs.stat(fullPath);
|
|
||||||
size += stats.size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to calculate directory size for ${dirPath}:`, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return size;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: Get last run version
|
|
||||||
*/
|
|
||||||
private async getLastRunVersion(): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const versionFile = path.join(this.userDataPath, ".last_version");
|
|
||||||
const version = await fs.readFile(versionFile, "utf8");
|
|
||||||
const trimmedVersion = version.trim();
|
|
||||||
logger.debug(`Last run version retrieved: ${trimmedVersion}`);
|
|
||||||
return trimmedVersion;
|
|
||||||
} catch {
|
|
||||||
logger.debug("No previous version file found");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: Save current version
|
|
||||||
*/
|
|
||||||
private async saveCurrentVersion(version: string): Promise<void> {
|
|
||||||
const versionFile = path.join(this.userDataPath, ".last_version");
|
|
||||||
await fs.writeFile(versionFile, version, "utf8");
|
|
||||||
logger.debug(`Current version saved: ${version}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
import { getAppPort } from "../../shared/ports";
|
|
||||||
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
|
|
||||||
export async function neonTemplateHook({
|
|
||||||
appId,
|
|
||||||
appName,
|
|
||||||
}: {
|
|
||||||
appId: number;
|
|
||||||
appName: string;
|
|
||||||
}) {
|
|
||||||
console.log("Creating Neon project");
|
|
||||||
const neonProject = await IpcClient.getInstance().createNeonProject({
|
|
||||||
name: appName,
|
|
||||||
appId: appId,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("Neon project created", neonProject);
|
|
||||||
await IpcClient.getInstance().setAppEnvVars({
|
|
||||||
appId: appId,
|
|
||||||
envVars: [
|
|
||||||
{
|
|
||||||
key: "POSTGRES_URL",
|
|
||||||
value: neonProject.connectionString,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "PAYLOAD_SECRET",
|
|
||||||
value: uuidv4(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "NEXT_PUBLIC_SERVER_URL",
|
|
||||||
value: `http://localhost:${getAppPort(appId)}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "GMAIL_USER",
|
|
||||||
value: "example@gmail.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "GOOGLE_APP_PASSWORD",
|
|
||||||
value: "GENERATE AT https://myaccount.google.com/apppasswords",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
console.log("App env vars set");
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import { useNavigate } from "@tanstack/react-router";
|
|
||||||
import { PlusCircle, Search } from "lucide-react";
|
|
||||||
import { useAtom, useSetAtom } from "jotai";
|
|
||||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
|
||||||
import {
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarMenu,
|
|
||||||
} from "@/components/ui/sidebar";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
|
||||||
import { useLoadApps } from "@/hooks/useLoadApps";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { AppSearchDialog } from "./AppSearchDialog";
|
|
||||||
import { useAddAppToFavorite } from "@/hooks/useAddAppToFavorite";
|
|
||||||
import { AppItem } from "./appItem";
|
|
||||||
export function AppList({ show }: { show?: boolean }) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [selectedAppId, setSelectedAppId] = useAtom(selectedAppIdAtom);
|
|
||||||
const setSelectedChatId = useSetAtom(selectedChatIdAtom);
|
|
||||||
const { apps, loading, error } = useLoadApps();
|
|
||||||
const { toggleFavorite, isLoading: isFavoriteLoading } =
|
|
||||||
useAddAppToFavorite();
|
|
||||||
// search dialog state
|
|
||||||
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
|
|
||||||
|
|
||||||
const allApps = useMemo(
|
|
||||||
() =>
|
|
||||||
apps.map((a) => ({
|
|
||||||
id: a.id,
|
|
||||||
name: a.name,
|
|
||||||
createdAt: a.createdAt,
|
|
||||||
matchedChatTitle: null,
|
|
||||||
matchedChatMessage: null,
|
|
||||||
})),
|
|
||||||
[apps],
|
|
||||||
);
|
|
||||||
|
|
||||||
const favoriteApps = useMemo(
|
|
||||||
() => apps.filter((app) => app.isFavorite),
|
|
||||||
[apps],
|
|
||||||
);
|
|
||||||
|
|
||||||
const nonFavoriteApps = useMemo(
|
|
||||||
() => apps.filter((app) => !app.isFavorite),
|
|
||||||
[apps],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!show) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAppClick = (id: number) => {
|
|
||||||
setSelectedAppId(id);
|
|
||||||
setSelectedChatId(null);
|
|
||||||
setIsSearchDialogOpen(false);
|
|
||||||
navigate({
|
|
||||||
to: "/",
|
|
||||||
search: { appId: id },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNewApp = () => {
|
|
||||||
navigate({ to: "/" });
|
|
||||||
// We'll eventually need a create app workflow
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleFavorite = (appId: number, e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggleFavorite(appId);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SidebarGroup
|
|
||||||
className="overflow-y-auto h-[calc(100vh-112px)]"
|
|
||||||
data-testid="app-list-container"
|
|
||||||
>
|
|
||||||
<SidebarGroupLabel>Your Apps</SidebarGroupLabel>
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<div className="flex flex-col space-y-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleNewApp}
|
|
||||||
variant="outline"
|
|
||||||
className="flex items-center justify-start gap-2 mx-2 py-2"
|
|
||||||
>
|
|
||||||
<PlusCircle size={16} />
|
|
||||||
<span>New App</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setIsSearchDialogOpen(!isSearchDialogOpen)}
|
|
||||||
variant="outline"
|
|
||||||
className="flex items-center justify-start gap-2 mx-2 py-3"
|
|
||||||
data-testid="search-apps-button"
|
|
||||||
>
|
|
||||||
<Search size={16} />
|
|
||||||
<span>Search Apps</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="py-2 px-4 text-sm text-gray-500">
|
|
||||||
Loading apps...
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="py-2 px-4 text-sm text-red-500">
|
|
||||||
Error loading apps
|
|
||||||
</div>
|
|
||||||
) : apps.length === 0 ? (
|
|
||||||
<div className="py-2 px-4 text-sm text-gray-500">
|
|
||||||
No apps found
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<SidebarMenu className="space-y-1" data-testid="app-list">
|
|
||||||
<SidebarGroupLabel>Favorite apps</SidebarGroupLabel>
|
|
||||||
{favoriteApps.map((app) => (
|
|
||||||
<AppItem
|
|
||||||
key={app.id}
|
|
||||||
app={app}
|
|
||||||
handleAppClick={handleAppClick}
|
|
||||||
selectedAppId={selectedAppId}
|
|
||||||
handleToggleFavorite={handleToggleFavorite}
|
|
||||||
isFavoriteLoading={isFavoriteLoading}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<SidebarGroupLabel>Other apps</SidebarGroupLabel>
|
|
||||||
{nonFavoriteApps.map((app) => (
|
|
||||||
<AppItem
|
|
||||||
key={app.id}
|
|
||||||
app={app}
|
|
||||||
handleAppClick={handleAppClick}
|
|
||||||
selectedAppId={selectedAppId}
|
|
||||||
handleToggleFavorite={handleToggleFavorite}
|
|
||||||
isFavoriteLoading={isFavoriteLoading}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SidebarMenu>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
<AppSearchDialog
|
|
||||||
open={isSearchDialogOpen}
|
|
||||||
onOpenChange={setIsSearchDialogOpen}
|
|
||||||
onSelectApp={handleAppClick}
|
|
||||||
allApps={allApps}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import {
|
|
||||||
CommandDialog,
|
|
||||||
CommandInput,
|
|
||||||
CommandList,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandItem,
|
|
||||||
} from "./ui/command";
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useSearchApps } from "@/hooks/useSearchApps";
|
|
||||||
import type { AppSearchResult } from "@/lib/schemas";
|
|
||||||
|
|
||||||
type AppSearchDialogProps = {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onSelectApp: (appId: number) => void;
|
|
||||||
allApps: AppSearchResult[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AppSearchDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onSelectApp,
|
|
||||||
allApps,
|
|
||||||
}: AppSearchDialogProps) {
|
|
||||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
|
||||||
function useDebouncedValue<T>(value: T, delay: number): T {
|
|
||||||
const [debounced, setDebounced] = useState<T>(value);
|
|
||||||
useEffect(() => {
|
|
||||||
const handle = setTimeout(() => setDebounced(value), delay);
|
|
||||||
return () => clearTimeout(handle);
|
|
||||||
}, [value, delay]);
|
|
||||||
return debounced;
|
|
||||||
}
|
|
||||||
|
|
||||||
const debouncedQuery = useDebouncedValue(searchQuery, 150);
|
|
||||||
const { apps: searchResults } = useSearchApps(debouncedQuery);
|
|
||||||
|
|
||||||
// Show all apps if search is empty, otherwise show search results
|
|
||||||
const appsToShow: AppSearchResult[] =
|
|
||||||
debouncedQuery.trim() === "" ? allApps : searchResults;
|
|
||||||
|
|
||||||
const commandFilter = (
|
|
||||||
value: string,
|
|
||||||
search: string,
|
|
||||||
keywords?: string[],
|
|
||||||
): number => {
|
|
||||||
const q = search.trim().toLowerCase();
|
|
||||||
if (!q) return 1;
|
|
||||||
const v = (value || "").toLowerCase();
|
|
||||||
if (v.includes(q)) {
|
|
||||||
// Higher score for earlier match in title/value
|
|
||||||
return 100 - Math.max(0, v.indexOf(q));
|
|
||||||
}
|
|
||||||
const foundInKeywords = (keywords || []).some((k) =>
|
|
||||||
(k || "").toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
return foundInKeywords ? 50 : 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getSnippet(
|
|
||||||
text: string,
|
|
||||||
query: string,
|
|
||||||
radius = 50,
|
|
||||||
): {
|
|
||||||
before: string;
|
|
||||||
match: string;
|
|
||||||
after: string;
|
|
||||||
raw: string;
|
|
||||||
} {
|
|
||||||
const q = query.trim();
|
|
||||||
const lowerText = text.toLowerCase();
|
|
||||||
const lowerQuery = q.toLowerCase();
|
|
||||||
const idx = lowerText.indexOf(lowerQuery);
|
|
||||||
if (idx === -1) {
|
|
||||||
const raw =
|
|
||||||
text.length > radius * 2 ? text.slice(0, radius * 2) + "…" : text;
|
|
||||||
return { before: "", match: "", after: "", raw };
|
|
||||||
}
|
|
||||||
const start = Math.max(0, idx - radius);
|
|
||||||
const end = Math.min(text.length, idx + q.length + radius);
|
|
||||||
const before = (start > 0 ? "…" : "") + text.slice(start, idx);
|
|
||||||
const match = text.slice(idx, idx + q.length);
|
|
||||||
const after =
|
|
||||||
text.slice(idx + q.length, end) + (end < text.length ? "…" : "");
|
|
||||||
return { before, match, after, raw: before + match + after };
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const down = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
|
||||||
e.preventDefault();
|
|
||||||
onOpenChange(!open);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", down);
|
|
||||||
return () => document.removeEventListener("keydown", down);
|
|
||||||
}, [open, onOpenChange]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CommandDialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
data-testid="app-search-dialog"
|
|
||||||
filter={commandFilter}
|
|
||||||
>
|
|
||||||
<CommandInput
|
|
||||||
placeholder="Search apps"
|
|
||||||
value={searchQuery}
|
|
||||||
onValueChange={setSearchQuery}
|
|
||||||
data-testid="app-search-input"
|
|
||||||
/>
|
|
||||||
<CommandList data-testid="app-search-list">
|
|
||||||
<CommandEmpty data-testid="app-search-empty">
|
|
||||||
No results found.
|
|
||||||
</CommandEmpty>
|
|
||||||
<CommandGroup heading="Apps" data-testid="app-search-group">
|
|
||||||
{appsToShow.map((app) => {
|
|
||||||
const isSearch = searchQuery.trim() !== "";
|
|
||||||
let snippet = null;
|
|
||||||
if (isSearch && app.matchedChatMessage) {
|
|
||||||
snippet = getSnippet(app.matchedChatMessage, searchQuery);
|
|
||||||
} else if (isSearch && app.matchedChatTitle) {
|
|
||||||
snippet = getSnippet(app.matchedChatTitle, searchQuery);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<CommandItem
|
|
||||||
key={app.id}
|
|
||||||
onSelect={() => onSelectApp(app.id)}
|
|
||||||
value={app.name + (snippet ? ` ${snippet.raw}` : "")}
|
|
||||||
keywords={snippet ? [snippet.raw] : []}
|
|
||||||
data-testid={`app-search-item-${app.id}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>{app.name}</span>
|
|
||||||
{snippet && (
|
|
||||||
<span className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
|
||||||
{snippet.before}
|
|
||||||
<mark className="bg-transparent underline decoration-2 decoration-primary">
|
|
||||||
{snippet.match}
|
|
||||||
</mark>
|
|
||||||
{snippet.after}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</CommandDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
||||||
import { Terminal } from "lucide-react";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { AppUpgrade } from "@/ipc/ipc_types";
|
|
||||||
|
|
||||||
export function AppUpgrades({ appId }: { appId: number | null }) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: upgrades,
|
|
||||||
isLoading,
|
|
||||||
error: queryError,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["app-upgrades", appId],
|
|
||||||
queryFn: () => {
|
|
||||||
if (!appId) {
|
|
||||||
return Promise.resolve([]);
|
|
||||||
}
|
|
||||||
return IpcClient.getInstance().getAppUpgrades({ appId });
|
|
||||||
},
|
|
||||||
enabled: !!appId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
mutate: executeUpgrade,
|
|
||||||
isPending: isUpgrading,
|
|
||||||
error: mutationError,
|
|
||||||
variables: upgradingVariables,
|
|
||||||
} = useMutation({
|
|
||||||
mutationFn: (upgradeId: string) => {
|
|
||||||
if (!appId) {
|
|
||||||
throw new Error("appId is not set");
|
|
||||||
}
|
|
||||||
return IpcClient.getInstance().executeAppUpgrade({
|
|
||||||
appId,
|
|
||||||
upgradeId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: (_, upgradeId) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["app-upgrades", appId] });
|
|
||||||
if (upgradeId === "capacitor") {
|
|
||||||
// Capacitor upgrade is done, so we need to invalidate the Capacitor
|
|
||||||
// query to show the new status.
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["is-capacitor", appId] });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleUpgrade = (upgradeId: string) => {
|
|
||||||
executeUpgrade(upgradeId);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!appId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="mt-6">
|
|
||||||
<h3 className="text-lg font-semibold mb-3 text-gray-900 dark:text-gray-100">
|
|
||||||
App Upgrades
|
|
||||||
</h3>
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queryError) {
|
|
||||||
return (
|
|
||||||
<div className="mt-6">
|
|
||||||
<h3 className="text-lg font-semibold mb-3 text-gray-900 dark:text-gray-100">
|
|
||||||
App Upgrades
|
|
||||||
</h3>
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTitle>Error loading upgrades</AlertTitle>
|
|
||||||
<AlertDescription>{queryError.message}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentUpgrades = upgrades?.filter((u) => u.isNeeded) ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-6">
|
|
||||||
<h3 className="text-lg font-semibold mb-3 text-gray-900 dark:text-gray-100">
|
|
||||||
App Upgrades
|
|
||||||
</h3>
|
|
||||||
{currentUpgrades.length === 0 ? (
|
|
||||||
<div
|
|
||||||
data-testid="no-app-upgrades-needed"
|
|
||||||
className="p-4 bg-green-50 border border-green-200 dark:bg-green-900/20 dark:border-green-800/50 rounded-lg text-sm text-green-800 dark:text-green-300"
|
|
||||||
>
|
|
||||||
App is up-to-date and has all Dyad capabilities enabled
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{currentUpgrades.map((upgrade: AppUpgrade) => (
|
|
||||||
<div
|
|
||||||
key={upgrade.id}
|
|
||||||
className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg flex justify-between items-start"
|
|
||||||
>
|
|
||||||
<div className="flex-grow">
|
|
||||||
<h4 className="font-semibold text-gray-800 dark:text-gray-200">
|
|
||||||
{upgrade.title}
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
{upgrade.description}
|
|
||||||
</p>
|
|
||||||
{mutationError && upgradingVariables === upgrade.id && (
|
|
||||||
<Alert
|
|
||||||
variant="destructive"
|
|
||||||
className="mt-3 dark:bg-destructive/15"
|
|
||||||
>
|
|
||||||
<Terminal className="h-4 w-4" />
|
|
||||||
<AlertTitle className="dark:text-red-200">
|
|
||||||
Upgrade Failed
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription className="text-xs text-red-400 dark:text-red-300">
|
|
||||||
{(mutationError as Error).message}{" "}
|
|
||||||
<a
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
upgrade.manualUpgradeUrl ?? "https://dyad.sh/docs",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="underline font-medium hover:dark:text-red-200"
|
|
||||||
>
|
|
||||||
Manual Upgrade Instructions
|
|
||||||
</a>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => handleUpgrade(upgrade.id)}
|
|
||||||
disabled={isUpgrading && upgradingVariables === upgrade.id}
|
|
||||||
className="ml-4 flex-shrink-0"
|
|
||||||
size="sm"
|
|
||||||
data-testid={`app-upgrade-${upgrade.id}`}
|
|
||||||
>
|
|
||||||
{isUpgrading && upgradingVariables === upgrade.id ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : null}
|
|
||||||
Upgrade
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { showInfo } from "@/lib/toast";
|
|
||||||
|
|
||||||
export function AutoApproveSwitch({
|
|
||||||
showToast = true,
|
|
||||||
}: {
|
|
||||||
showToast?: boolean;
|
|
||||||
}) {
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
return (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Switch
|
|
||||||
id="auto-approve"
|
|
||||||
checked={settings?.autoApproveChanges}
|
|
||||||
onCheckedChange={() => {
|
|
||||||
updateSettings({ autoApproveChanges: !settings?.autoApproveChanges });
|
|
||||||
if (!settings?.autoApproveChanges && showToast) {
|
|
||||||
showInfo("You can disable auto-approve in the Settings.");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="auto-approve">Auto-approve</Label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
|
|
||||||
import { showInfo } from "@/lib/toast";
|
|
||||||
|
|
||||||
export function AutoFixProblemsSwitch({
|
|
||||||
showToast = false,
|
|
||||||
}: {
|
|
||||||
showToast?: boolean;
|
|
||||||
}) {
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
return (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Switch
|
|
||||||
id="auto-fix-problems"
|
|
||||||
checked={settings?.enableAutoFixProblems}
|
|
||||||
onCheckedChange={() => {
|
|
||||||
updateSettings({
|
|
||||||
enableAutoFixProblems: !settings?.enableAutoFixProblems,
|
|
||||||
});
|
|
||||||
if (!settings?.enableAutoFixProblems && showToast) {
|
|
||||||
showInfo("You can disable Auto-fix problems in the Settings page.");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="auto-fix-problems">Auto-fix problems</Label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
|
|
||||||
export function AutoUpdateSwitch() {
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
|
|
||||||
if (!settings) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Switch
|
|
||||||
id="enable-auto-update"
|
|
||||||
checked={settings.enableAutoUpdate}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
updateSettings({ enableAutoUpdate: checked });
|
|
||||||
toast("Auto-update settings changed", {
|
|
||||||
description:
|
|
||||||
"You will need to restart Dyad for your settings to take effect.",
|
|
||||||
action: {
|
|
||||||
label: "Restart Dyad",
|
|
||||||
onClick: () => {
|
|
||||||
IpcClient.getInstance().restartDyad();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="enable-auto-update">Auto-update</Label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
import { Dialog, DialogTitle } from "@radix-ui/react-dialog";
|
|
||||||
import { DialogContent, DialogHeader } from "./ui/dialog";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import { BugIcon, Camera } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { ScreenshotSuccessDialog } from "./ScreenshotSuccessDialog";
|
|
||||||
|
|
||||||
interface BugScreenshotDialogProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
handleReportBug: () => Promise<void>;
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
export function BugScreenshotDialog({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
handleReportBug,
|
|
||||||
isLoading,
|
|
||||||
}: BugScreenshotDialogProps) {
|
|
||||||
const [isScreenshotSuccessOpen, setIsScreenshotSuccessOpen] = useState(false);
|
|
||||||
const [screenshotError, setScreenshotError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleReportBugWithScreenshot = async () => {
|
|
||||||
setScreenshotError(null);
|
|
||||||
onClose();
|
|
||||||
setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
await IpcClient.getInstance().takeScreenshot();
|
|
||||||
setIsScreenshotSuccessOpen(true);
|
|
||||||
} catch (error) {
|
|
||||||
setScreenshotError(
|
|
||||||
error instanceof Error ? error.message : "Failed to take screenshot",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, 200); // Small delay for dialog to close
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Take a screenshot?</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex flex-col space-y-4 w-full">
|
|
||||||
<div className="flex flex-col space-y-2">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onClick={handleReportBugWithScreenshot}
|
|
||||||
className="w-full py-6 border-primary/50 shadow-sm shadow-primary/10 transition-all hover:shadow-md hover:shadow-primary/15"
|
|
||||||
>
|
|
||||||
<Camera className="mr-2 h-5 w-5" /> Take a screenshot
|
|
||||||
(recommended)
|
|
||||||
</Button>
|
|
||||||
<p className="text-sm text-muted-foreground px-2">
|
|
||||||
You'll get better and faster responses if you do this!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col space-y-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
handleReportBug();
|
|
||||||
}}
|
|
||||||
className="w-full py-6 bg-(--background-lightest)"
|
|
||||||
>
|
|
||||||
<BugIcon className="mr-2 h-5 w-5" />{" "}
|
|
||||||
{isLoading
|
|
||||||
? "Preparing Report..."
|
|
||||||
: "File bug report without screenshot"}
|
|
||||||
</Button>
|
|
||||||
<p className="text-sm text-muted-foreground px-2">
|
|
||||||
We'll still try to respond but might not be able to help as much.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{screenshotError && (
|
|
||||||
<p className="text-sm text-destructive px-2">
|
|
||||||
Failed to take screenshot: {screenshotError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
<ScreenshotSuccessDialog
|
|
||||||
isOpen={isScreenshotSuccessOpen}
|
|
||||||
onClose={() => setIsScreenshotSuccessOpen(false)}
|
|
||||||
handleReportBug={handleReportBug}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
import { showSuccess } from "@/lib/toast";
|
|
||||||
import {
|
|
||||||
Smartphone,
|
|
||||||
TabletSmartphone,
|
|
||||||
Loader2,
|
|
||||||
ExternalLink,
|
|
||||||
Copy,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
|
|
||||||
interface CapacitorControlsProps {
|
|
||||||
appId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type CapacitorStatus = "idle" | "syncing" | "opening";
|
|
||||||
|
|
||||||
export function CapacitorControls({ appId }: CapacitorControlsProps) {
|
|
||||||
const [errorDialogOpen, setErrorDialogOpen] = useState(false);
|
|
||||||
const [errorDetails, setErrorDetails] = useState<{
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
} | null>(null);
|
|
||||||
const [iosStatus, setIosStatus] = useState<CapacitorStatus>("idle");
|
|
||||||
const [androidStatus, setAndroidStatus] = useState<CapacitorStatus>("idle");
|
|
||||||
|
|
||||||
// Check if Capacitor is installed
|
|
||||||
const { data: isCapacitor, isLoading } = useQuery({
|
|
||||||
queryKey: ["is-capacitor", appId],
|
|
||||||
queryFn: () => IpcClient.getInstance().isCapacitor({ appId }),
|
|
||||||
enabled: appId !== undefined && appId !== null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const showErrorDialog = (title: string, error: unknown) => {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
setErrorDetails({ title, message: errorMessage });
|
|
||||||
setErrorDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sync and open iOS mutation
|
|
||||||
const syncAndOpenIosMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
setIosStatus("syncing");
|
|
||||||
// First sync
|
|
||||||
await IpcClient.getInstance().syncCapacitor({ appId });
|
|
||||||
setIosStatus("opening");
|
|
||||||
// Then open iOS
|
|
||||||
await IpcClient.getInstance().openIos({ appId });
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
setIosStatus("idle");
|
|
||||||
showSuccess("Synced and opened iOS project in Xcode");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
setIosStatus("idle");
|
|
||||||
showErrorDialog("Failed to sync and open iOS project", error);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync and open Android mutation
|
|
||||||
const syncAndOpenAndroidMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
setAndroidStatus("syncing");
|
|
||||||
// First sync
|
|
||||||
await IpcClient.getInstance().syncCapacitor({ appId });
|
|
||||||
setAndroidStatus("opening");
|
|
||||||
// Then open Android
|
|
||||||
await IpcClient.getInstance().openAndroid({ appId });
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
setAndroidStatus("idle");
|
|
||||||
showSuccess("Synced and opened Android project in Android Studio");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
setAndroidStatus("idle");
|
|
||||||
showErrorDialog("Failed to sync and open Android project", error);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper function to get button text based on status
|
|
||||||
const getIosButtonText = () => {
|
|
||||||
switch (iosStatus) {
|
|
||||||
case "syncing":
|
|
||||||
return { main: "Syncing...", sub: "Building app" };
|
|
||||||
case "opening":
|
|
||||||
return { main: "Opening...", sub: "Launching Xcode" };
|
|
||||||
default:
|
|
||||||
return { main: "Sync & Open iOS", sub: "Xcode" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAndroidButtonText = () => {
|
|
||||||
switch (androidStatus) {
|
|
||||||
case "syncing":
|
|
||||||
return { main: "Syncing...", sub: "Building app" };
|
|
||||||
case "opening":
|
|
||||||
return { main: "Opening...", sub: "Launching Android Studio" };
|
|
||||||
default:
|
|
||||||
return { main: "Sync & Open Android", sub: "Android Studio" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Don't render anything if loading or if Capacitor is not installed
|
|
||||||
if (isLoading || !isCapacitor) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const iosButtonText = getIosButtonText();
|
|
||||||
const androidButtonText = getAndroidButtonText();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Card className="mt-1" data-testid="capacitor-controls">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center justify-between">
|
|
||||||
Mobile Development
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
// TODO: Add actual help link
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
"https://dyad.sh/docs/guides/mobile-app#troubleshooting",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 flex items-center gap-1"
|
|
||||||
>
|
|
||||||
Need help?
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Sync and open your Capacitor mobile projects
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={() => syncAndOpenIosMutation.mutate()}
|
|
||||||
disabled={syncAndOpenIosMutation.isPending}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-2 h-10"
|
|
||||||
>
|
|
||||||
{syncAndOpenIosMutation.isPending ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Smartphone className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<div className="text-left">
|
|
||||||
<div className="text-xs font-medium">{iosButtonText.main}</div>
|
|
||||||
<div className="text-xs text-gray-500">{iosButtonText.sub}</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => syncAndOpenAndroidMutation.mutate()}
|
|
||||||
disabled={syncAndOpenAndroidMutation.isPending}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-2 h-10"
|
|
||||||
>
|
|
||||||
{syncAndOpenAndroidMutation.isPending ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<TabletSmartphone className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<div className="text-left">
|
|
||||||
<div className="text-xs font-medium">
|
|
||||||
{androidButtonText.main}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{androidButtonText.sub}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Error Dialog */}
|
|
||||||
<Dialog open={errorDialogOpen} onOpenChange={setErrorDialogOpen}>
|
|
||||||
<DialogContent className="max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-red-600 dark:text-red-400">
|
|
||||||
{errorDetails?.title}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
An error occurred while running the Capacitor command. See details
|
|
||||||
below:
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{errorDetails && (
|
|
||||||
<div className="relative">
|
|
||||||
<div className="max-h-[50vh] w-full max-w-md rounded border p-4 bg-gray-50 dark:bg-gray-900 overflow-y-auto">
|
|
||||||
<pre className="text-xs whitespace-pre-wrap font-mono">
|
|
||||||
{errorDetails.message}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard.writeText(errorDetails.message);
|
|
||||||
showSuccess("Error details copied to clipboard");
|
|
||||||
}}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="absolute top-2 right-2 h-8 w-8 p-0"
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
if (errorDetails) {
|
|
||||||
navigator.clipboard.writeText(errorDetails.message);
|
|
||||||
showSuccess("Error details copied to clipboard");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
Copy Error
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setErrorDialogOpen(false)}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { ContextFilesPicker } from "./ContextFilesPicker";
|
|
||||||
import { ModelPicker } from "./ModelPicker";
|
|
||||||
import { ProModeSelector } from "./ProModeSelector";
|
|
||||||
import { ChatModeSelector } from "./ChatModeSelector";
|
|
||||||
import { McpToolsPicker } from "@/components/McpToolsPicker";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
|
|
||||||
export function ChatInputControls({
|
|
||||||
showContextFilesPicker = false,
|
|
||||||
}: {
|
|
||||||
showContextFilesPicker?: boolean;
|
|
||||||
}) {
|
|
||||||
const { settings } = useSettings();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex">
|
|
||||||
<ChatModeSelector />
|
|
||||||
{settings?.selectedChatMode === "agent" && (
|
|
||||||
<>
|
|
||||||
<div className="w-1.5"></div>
|
|
||||||
<McpToolsPicker />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="w-1.5"></div>
|
|
||||||
<ModelPicker />
|
|
||||||
<div className="w-1.5"></div>
|
|
||||||
<ProModeSelector />
|
|
||||||
<div className="w-1"></div>
|
|
||||||
{showContextFilesPicker && (
|
|
||||||
<>
|
|
||||||
<ContextFilesPicker />
|
|
||||||
<div className="w-0.5"></div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
import { formatDistanceToNow } from "date-fns";
|
|
||||||
import { PlusCircle, MoreVertical, Trash2, Edit3, Search } from "lucide-react";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
|
||||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
|
||||||
import { dropdownOpenAtom } from "@/atoms/uiAtoms";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
import { showError, showSuccess } from "@/lib/toast";
|
|
||||||
import {
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuItem,
|
|
||||||
} from "@/components/ui/sidebar";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { useChats } from "@/hooks/useChats";
|
|
||||||
import { RenameChatDialog } from "@/components/chat/RenameChatDialog";
|
|
||||||
import { DeleteChatDialog } from "@/components/chat/DeleteChatDialog";
|
|
||||||
|
|
||||||
import { ChatSearchDialog } from "./ChatSearchDialog";
|
|
||||||
import { useSelectChat } from "@/hooks/useSelectChat";
|
|
||||||
|
|
||||||
export function ChatList({ show }: { show?: boolean }) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom);
|
|
||||||
const [selectedAppId] = useAtom(selectedAppIdAtom);
|
|
||||||
const [, setIsDropdownOpen] = useAtom(dropdownOpenAtom);
|
|
||||||
|
|
||||||
const { chats, loading, refreshChats } = useChats(selectedAppId);
|
|
||||||
const routerState = useRouterState();
|
|
||||||
const isChatRoute = routerState.location.pathname === "/chat";
|
|
||||||
|
|
||||||
// Rename dialog state
|
|
||||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
|
||||||
const [renameChatId, setRenameChatId] = useState<number | null>(null);
|
|
||||||
const [renameChatTitle, setRenameChatTitle] = useState("");
|
|
||||||
|
|
||||||
// Delete dialog state
|
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
||||||
const [deleteChatId, setDeleteChatId] = useState<number | null>(null);
|
|
||||||
const [deleteChatTitle, setDeleteChatTitle] = useState("");
|
|
||||||
|
|
||||||
// search dialog state
|
|
||||||
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
|
|
||||||
const { selectChat } = useSelectChat();
|
|
||||||
|
|
||||||
// Update selectedChatId when route changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (isChatRoute) {
|
|
||||||
const id = routerState.location.search.id;
|
|
||||||
if (id) {
|
|
||||||
console.log("Setting selected chat id to", id);
|
|
||||||
setSelectedChatId(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isChatRoute, routerState.location.search, setSelectedChatId]);
|
|
||||||
|
|
||||||
if (!show) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleChatClick = ({
|
|
||||||
chatId,
|
|
||||||
appId,
|
|
||||||
}: {
|
|
||||||
chatId: number;
|
|
||||||
appId: number;
|
|
||||||
}) => {
|
|
||||||
selectChat({ chatId, appId });
|
|
||||||
setIsSearchDialogOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNewChat = async () => {
|
|
||||||
// Only create a new chat if an app is selected
|
|
||||||
if (selectedAppId) {
|
|
||||||
try {
|
|
||||||
// Create a new chat with an empty title for now
|
|
||||||
const chatId = await IpcClient.getInstance().createChat(selectedAppId);
|
|
||||||
|
|
||||||
// Navigate to the new chat
|
|
||||||
setSelectedChatId(chatId);
|
|
||||||
navigate({
|
|
||||||
to: "/chat",
|
|
||||||
search: { id: chatId },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Refresh the chat list
|
|
||||||
await refreshChats();
|
|
||||||
} catch (error) {
|
|
||||||
// DO A TOAST
|
|
||||||
showError(`Failed to create new chat: ${(error as any).toString()}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If no app is selected, navigate to home page
|
|
||||||
navigate({ to: "/" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteChat = async (chatId: number) => {
|
|
||||||
try {
|
|
||||||
await IpcClient.getInstance().deleteChat(chatId);
|
|
||||||
showSuccess("Chat deleted successfully");
|
|
||||||
|
|
||||||
// If the deleted chat was selected, navigate to home
|
|
||||||
if (selectedChatId === chatId) {
|
|
||||||
setSelectedChatId(null);
|
|
||||||
navigate({ to: "/chat" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh the chat list
|
|
||||||
await refreshChats();
|
|
||||||
} catch (error) {
|
|
||||||
showError(`Failed to delete chat: ${(error as any).toString()}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteChatClick = (chatId: number, chatTitle: string) => {
|
|
||||||
setDeleteChatId(chatId);
|
|
||||||
setDeleteChatTitle(chatTitle);
|
|
||||||
setIsDeleteDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirmDelete = async () => {
|
|
||||||
if (deleteChatId !== null) {
|
|
||||||
await handleDeleteChat(deleteChatId);
|
|
||||||
setIsDeleteDialogOpen(false);
|
|
||||||
setDeleteChatId(null);
|
|
||||||
setDeleteChatTitle("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRenameChat = (chatId: number, currentTitle: string) => {
|
|
||||||
setRenameChatId(chatId);
|
|
||||||
setRenameChatTitle(currentTitle);
|
|
||||||
setIsRenameDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRenameDialogClose = (open: boolean) => {
|
|
||||||
setIsRenameDialogOpen(open);
|
|
||||||
if (!open) {
|
|
||||||
setRenameChatId(null);
|
|
||||||
setRenameChatTitle("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SidebarGroup
|
|
||||||
className="overflow-y-auto h-[calc(100vh-112px)]"
|
|
||||||
data-testid="chat-list-container"
|
|
||||||
>
|
|
||||||
<SidebarGroupLabel>Recent Chats</SidebarGroupLabel>
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<div className="flex flex-col space-y-4">
|
|
||||||
<Button
|
|
||||||
onClick={handleNewChat}
|
|
||||||
variant="outline"
|
|
||||||
className="flex items-center justify-start gap-2 mx-2 py-3"
|
|
||||||
>
|
|
||||||
<PlusCircle size={16} />
|
|
||||||
<span>New Chat</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setIsSearchDialogOpen(!isSearchDialogOpen)}
|
|
||||||
variant="outline"
|
|
||||||
className="flex items-center justify-start gap-2 mx-2 py-3"
|
|
||||||
data-testid="search-chats-button"
|
|
||||||
>
|
|
||||||
<Search size={16} />
|
|
||||||
<span>Search chats</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="py-3 px-4 text-sm text-gray-500">
|
|
||||||
Loading chats...
|
|
||||||
</div>
|
|
||||||
) : chats.length === 0 ? (
|
|
||||||
<div className="py-3 px-4 text-sm text-gray-500">
|
|
||||||
No chats found
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<SidebarMenu className="space-y-1">
|
|
||||||
{chats.map((chat) => (
|
|
||||||
<SidebarMenuItem key={chat.id} className="mb-1">
|
|
||||||
<div className="flex w-[175px] items-center">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
handleChatClick({
|
|
||||||
chatId: chat.id,
|
|
||||||
appId: chat.appId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className={`justify-start w-full text-left py-3 pr-1 hover:bg-sidebar-accent/80 ${
|
|
||||||
selectedChatId === chat.id
|
|
||||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col w-full">
|
|
||||||
<span className="truncate">
|
|
||||||
{chat.title || "New Chat"}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
{formatDistanceToNow(new Date(chat.createdAt), {
|
|
||||||
addSuffix: true,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{selectedChatId === chat.id && (
|
|
||||||
<DropdownMenu
|
|
||||||
modal={false}
|
|
||||||
onOpenChange={(open) => setIsDropdownOpen(open)}
|
|
||||||
>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="ml-1 w-4"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<MoreVertical className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="end"
|
|
||||||
className="space-y-1 p-2"
|
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
handleRenameChat(chat.id, chat.title || "")
|
|
||||||
}
|
|
||||||
className="px-3 py-2"
|
|
||||||
>
|
|
||||||
<Edit3 className="mr-2 h-4 w-4" />
|
|
||||||
<span>Rename Chat</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
handleDeleteChatClick(
|
|
||||||
chat.id,
|
|
||||||
chat.title || "New Chat",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="px-3 py-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/50 focus:bg-red-50 dark:focus:bg-red-950/50"
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
<span>Delete Chat</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
))}
|
|
||||||
</SidebarMenu>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
|
|
||||||
{/* Rename Chat Dialog */}
|
|
||||||
{renameChatId !== null && (
|
|
||||||
<RenameChatDialog
|
|
||||||
chatId={renameChatId}
|
|
||||||
currentTitle={renameChatTitle}
|
|
||||||
isOpen={isRenameDialogOpen}
|
|
||||||
onOpenChange={handleRenameDialogClose}
|
|
||||||
onRename={refreshChats}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete Chat Dialog */}
|
|
||||||
<DeleteChatDialog
|
|
||||||
isOpen={isDeleteDialogOpen}
|
|
||||||
onOpenChange={setIsDeleteDialogOpen}
|
|
||||||
onConfirmDelete={handleConfirmDelete}
|
|
||||||
chatTitle={deleteChatTitle}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Chat Search Dialog */}
|
|
||||||
<ChatSearchDialog
|
|
||||||
open={isSearchDialogOpen}
|
|
||||||
onOpenChange={setIsSearchDialogOpen}
|
|
||||||
onSelectChat={handleChatClick}
|
|
||||||
appId={selectedAppId}
|
|
||||||
allChats={chats}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import {
|
|
||||||
MiniSelectTrigger,
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import type { ChatMode } from "@/lib/schemas";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { detectIsMac } from "@/hooks/useChatModeToggle";
|
|
||||||
|
|
||||||
export function ChatModeSelector() {
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
|
|
||||||
const selectedMode = settings?.selectedChatMode || "build";
|
|
||||||
|
|
||||||
const handleModeChange = (value: string) => {
|
|
||||||
updateSettings({ selectedChatMode: value as ChatMode });
|
|
||||||
};
|
|
||||||
|
|
||||||
const getModeDisplayName = (mode: ChatMode) => {
|
|
||||||
switch (mode) {
|
|
||||||
case "build":
|
|
||||||
return "Build";
|
|
||||||
case "ask":
|
|
||||||
return "Ask";
|
|
||||||
case "agent":
|
|
||||||
return "Build (MCP)";
|
|
||||||
default:
|
|
||||||
return "Build";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const isMac = detectIsMac();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Select value={selectedMode} onValueChange={handleModeChange}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<MiniSelectTrigger
|
|
||||||
data-testid="chat-mode-selector"
|
|
||||||
className={cn(
|
|
||||||
"h-6 w-fit px-1.5 py-0 text-xs-sm font-medium shadow-none gap-0.5",
|
|
||||||
selectedMode === "build"
|
|
||||||
? "bg-background hover:bg-muted/50 focus:bg-muted/50"
|
|
||||||
: "bg-primary/10 hover:bg-primary/20 focus:bg-primary/20 text-primary border-primary/20 dark:bg-primary/20 dark:hover:bg-primary/30 dark:focus:bg-primary/30",
|
|
||||||
)}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<SelectValue>{getModeDisplayName(selectedMode)}</SelectValue>
|
|
||||||
</MiniSelectTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>Open mode menu</span>
|
|
||||||
<span className="text-xs text-gray-200 dark:text-gray-500">
|
|
||||||
{isMac ? "⌘ + ." : "Ctrl + ."} to toggle
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<SelectContent align="start" onCloseAutoFocus={(e) => e.preventDefault()}>
|
|
||||||
<SelectItem value="build">
|
|
||||||
<div className="flex flex-col items-start">
|
|
||||||
<span className="font-medium">Build</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Generate and edit code
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="ask">
|
|
||||||
<div className="flex flex-col items-start">
|
|
||||||
<span className="font-medium">Ask</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Ask questions about the app
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="agent">
|
|
||||||
<div className="flex flex-col items-start">
|
|
||||||
<span className="font-medium">Build with MCP (experimental)</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Like Build, but can use tools (MCP) to generate code
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
|
||||||
import {
|
|
||||||
chatMessagesByIdAtom,
|
|
||||||
chatStreamCountByIdAtom,
|
|
||||||
isStreamingByIdAtom,
|
|
||||||
} from "../atoms/chatAtoms";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
|
|
||||||
import { ChatHeader } from "./chat/ChatHeader";
|
|
||||||
import { MessagesList } from "./chat/MessagesList";
|
|
||||||
import { ChatInput } from "./chat/ChatInput";
|
|
||||||
import { VersionPane } from "./chat/VersionPane";
|
|
||||||
import { ChatError } from "./chat/ChatError";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ArrowDown } from "lucide-react";
|
|
||||||
|
|
||||||
interface ChatPanelProps {
|
|
||||||
chatId?: number;
|
|
||||||
isPreviewOpen: boolean;
|
|
||||||
onTogglePreview: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatPanel({
|
|
||||||
chatId,
|
|
||||||
isPreviewOpen,
|
|
||||||
onTogglePreview,
|
|
||||||
}: ChatPanelProps) {
|
|
||||||
const messagesById = useAtomValue(chatMessagesByIdAtom);
|
|
||||||
const setMessagesById = useSetAtom(chatMessagesByIdAtom);
|
|
||||||
const [isVersionPaneOpen, setIsVersionPaneOpen] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const streamCountById = useAtomValue(chatStreamCountByIdAtom);
|
|
||||||
const isStreamingById = useAtomValue(isStreamingByIdAtom);
|
|
||||||
// Reference to store the processed prompt so we don't submit it twice
|
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
// Scroll-related properties
|
|
||||||
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
|
||||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
|
||||||
const userScrollTimeoutRef = useRef<number | null>(null);
|
|
||||||
const lastScrollTopRef = useRef<number>(0);
|
|
||||||
const scrollToBottom = (behavior: ScrollBehavior = "smooth") => {
|
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleScrollButtonClick = () => {
|
|
||||||
if (!messagesContainerRef.current) return;
|
|
||||||
|
|
||||||
scrollToBottom("smooth");
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDistanceFromBottom = () => {
|
|
||||||
if (!messagesContainerRef.current) return 0;
|
|
||||||
const container = messagesContainerRef.current;
|
|
||||||
return (
|
|
||||||
container.scrollHeight - (container.scrollTop + container.clientHeight)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isNearBottom = (threshold: number = 100) => {
|
|
||||||
return getDistanceFromBottom() <= threshold;
|
|
||||||
};
|
|
||||||
|
|
||||||
const scrollAwayThreshold = 150; // pixels from bottom to consider "scrolled away"
|
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
|
||||||
if (!messagesContainerRef.current) return;
|
|
||||||
|
|
||||||
const container = messagesContainerRef.current;
|
|
||||||
const distanceFromBottom =
|
|
||||||
container.scrollHeight - (container.scrollTop + container.clientHeight);
|
|
||||||
|
|
||||||
// User has scrolled away from bottom
|
|
||||||
if (distanceFromBottom > scrollAwayThreshold) {
|
|
||||||
setIsUserScrolling(true);
|
|
||||||
setShowScrollButton(true);
|
|
||||||
|
|
||||||
if (userScrollTimeoutRef.current) {
|
|
||||||
window.clearTimeout(userScrollTimeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
userScrollTimeoutRef.current = window.setTimeout(() => {
|
|
||||||
setIsUserScrolling(false);
|
|
||||||
}, 2000); // Increased timeout to 2 seconds
|
|
||||||
} else {
|
|
||||||
// User is near bottom
|
|
||||||
setIsUserScrolling(false);
|
|
||||||
setShowScrollButton(false);
|
|
||||||
}
|
|
||||||
lastScrollTopRef.current = container.scrollTop;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const streamCount = chatId ? (streamCountById.get(chatId) ?? 0) : 0;
|
|
||||||
console.log("streamCount - scrolling to bottom", streamCount);
|
|
||||||
scrollToBottom();
|
|
||||||
}, [
|
|
||||||
chatId,
|
|
||||||
chatId ? (streamCountById.get(chatId) ?? 0) : 0,
|
|
||||||
chatId ? (isStreamingById.get(chatId) ?? false) : false,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const container = messagesContainerRef.current;
|
|
||||||
if (container) {
|
|
||||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (container) {
|
|
||||||
container.removeEventListener("scroll", handleScroll);
|
|
||||||
}
|
|
||||||
if (userScrollTimeoutRef.current) {
|
|
||||||
window.clearTimeout(userScrollTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [handleScroll]);
|
|
||||||
|
|
||||||
const fetchChatMessages = useCallback(async () => {
|
|
||||||
if (!chatId) {
|
|
||||||
// no-op when no chat
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const chat = await IpcClient.getInstance().getChat(chatId);
|
|
||||||
setMessagesById((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(chatId, chat.messages);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [chatId, setMessagesById]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchChatMessages();
|
|
||||||
}, [fetchChatMessages]);
|
|
||||||
|
|
||||||
const messages = chatId ? (messagesById.get(chatId) ?? []) : [];
|
|
||||||
const isStreaming = chatId ? (isStreamingById.get(chatId) ?? false) : false;
|
|
||||||
|
|
||||||
// Auto-scroll effect when messages change during streaming
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!isUserScrolling &&
|
|
||||||
isStreaming &&
|
|
||||||
messagesContainerRef.current &&
|
|
||||||
messages.length > 0
|
|
||||||
) {
|
|
||||||
// Only auto-scroll if user is close to bottom
|
|
||||||
if (isNearBottom(280)) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
scrollToBottom("instant");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [messages, isUserScrolling, isStreaming]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<ChatHeader
|
|
||||||
isVersionPaneOpen={isVersionPaneOpen}
|
|
||||||
isPreviewOpen={isPreviewOpen}
|
|
||||||
onTogglePreview={onTogglePreview}
|
|
||||||
onVersionClick={() => setIsVersionPaneOpen(!isVersionPaneOpen)}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
|
||||||
{!isVersionPaneOpen && (
|
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
|
||||||
<div className="flex-1 relative overflow-hidden">
|
|
||||||
<MessagesList
|
|
||||||
messages={messages}
|
|
||||||
messagesEndRef={messagesEndRef}
|
|
||||||
ref={messagesContainerRef}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scroll to bottom button */}
|
|
||||||
{showScrollButton && (
|
|
||||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-10">
|
|
||||||
<Button
|
|
||||||
onClick={handleScrollButtonClick}
|
|
||||||
size="icon"
|
|
||||||
className="rounded-full shadow-lg hover:shadow-xl transition-all border border-border/50 backdrop-blur-sm bg-background/95 hover:bg-accent"
|
|
||||||
variant="outline"
|
|
||||||
title={"Scroll to bottom"}
|
|
||||||
>
|
|
||||||
<ArrowDown className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ChatError error={error} onDismiss={() => setError(null)} />
|
|
||||||
<ChatInput chatId={chatId} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<VersionPane
|
|
||||||
isVisible={isVersionPaneOpen}
|
|
||||||
onClose={() => setIsVersionPaneOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import {
|
|
||||||
CommandDialog,
|
|
||||||
CommandInput,
|
|
||||||
CommandList,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandItem,
|
|
||||||
} from "./ui/command";
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useSearchChats } from "@/hooks/useSearchChats";
|
|
||||||
import type { ChatSummary, ChatSearchResult } from "@/lib/schemas";
|
|
||||||
|
|
||||||
type ChatSearchDialogProps = {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onSelectChat: ({ chatId, appId }: { chatId: number; appId: number }) => void;
|
|
||||||
appId: number | null;
|
|
||||||
allChats: ChatSummary[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ChatSearchDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
appId,
|
|
||||||
onSelectChat,
|
|
||||||
allChats,
|
|
||||||
}: ChatSearchDialogProps) {
|
|
||||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
|
||||||
function useDebouncedValue<T>(value: T, delay: number): T {
|
|
||||||
const [debounced, setDebounced] = useState<T>(value);
|
|
||||||
useEffect(() => {
|
|
||||||
const handle = setTimeout(() => setDebounced(value), delay);
|
|
||||||
return () => clearTimeout(handle);
|
|
||||||
}, [value, delay]);
|
|
||||||
return debounced;
|
|
||||||
}
|
|
||||||
|
|
||||||
const debouncedQuery = useDebouncedValue(searchQuery, 150);
|
|
||||||
const { chats: searchResults } = useSearchChats(appId, debouncedQuery);
|
|
||||||
|
|
||||||
// Show all chats if search is empty, otherwise show search results
|
|
||||||
const chatsToShow = debouncedQuery.trim() === "" ? allChats : searchResults;
|
|
||||||
|
|
||||||
const commandFilter = (
|
|
||||||
value: string,
|
|
||||||
search: string,
|
|
||||||
keywords?: string[],
|
|
||||||
): number => {
|
|
||||||
const q = search.trim().toLowerCase();
|
|
||||||
if (!q) return 1;
|
|
||||||
const v = (value || "").toLowerCase();
|
|
||||||
if (v.includes(q)) {
|
|
||||||
// Higher score for earlier match in title/value
|
|
||||||
return 100 - Math.max(0, v.indexOf(q));
|
|
||||||
}
|
|
||||||
const foundInKeywords = (keywords || []).some((k) =>
|
|
||||||
(k || "").toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
return foundInKeywords ? 50 : 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getSnippet(
|
|
||||||
text: string,
|
|
||||||
query: string,
|
|
||||||
radius = 50,
|
|
||||||
): {
|
|
||||||
before: string;
|
|
||||||
match: string;
|
|
||||||
after: string;
|
|
||||||
raw: string;
|
|
||||||
} {
|
|
||||||
const q = query.trim();
|
|
||||||
const lowerText = text;
|
|
||||||
const lowerQuery = q.toLowerCase();
|
|
||||||
const idx = lowerText.toLowerCase().indexOf(lowerQuery);
|
|
||||||
if (idx === -1) {
|
|
||||||
const raw =
|
|
||||||
text.length > radius * 2 ? text.slice(0, radius * 2) + "…" : text;
|
|
||||||
return { before: "", match: "", after: "", raw };
|
|
||||||
}
|
|
||||||
const start = Math.max(0, idx - radius);
|
|
||||||
const end = Math.min(text.length, idx + q.length + radius);
|
|
||||||
const before = (start > 0 ? "…" : "") + text.slice(start, idx);
|
|
||||||
const match = text.slice(idx, idx + q.length);
|
|
||||||
const after =
|
|
||||||
text.slice(idx + q.length, end) + (end < text.length ? "…" : "");
|
|
||||||
return { before, match, after, raw: before + match + after };
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const down = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
|
||||||
e.preventDefault();
|
|
||||||
onOpenChange(!open);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", down);
|
|
||||||
return () => document.removeEventListener("keydown", down);
|
|
||||||
}, [open, onOpenChange]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CommandDialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
data-testid="chat-search-dialog"
|
|
||||||
filter={commandFilter}
|
|
||||||
>
|
|
||||||
<CommandInput
|
|
||||||
placeholder="Search chats"
|
|
||||||
value={searchQuery}
|
|
||||||
onValueChange={setSearchQuery}
|
|
||||||
/>
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>No results found.</CommandEmpty>
|
|
||||||
<CommandGroup heading="Chats">
|
|
||||||
{chatsToShow.map((chat) => {
|
|
||||||
const isSearch = searchQuery.trim() !== "";
|
|
||||||
const hasSnippet =
|
|
||||||
isSearch &&
|
|
||||||
"matchedMessageContent" in chat &&
|
|
||||||
(chat as ChatSearchResult).matchedMessageContent;
|
|
||||||
const snippet = hasSnippet
|
|
||||||
? getSnippet(
|
|
||||||
(chat as ChatSearchResult).matchedMessageContent as string,
|
|
||||||
searchQuery,
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
return (
|
|
||||||
<CommandItem
|
|
||||||
key={chat.id}
|
|
||||||
onSelect={() =>
|
|
||||||
onSelectChat({ chatId: chat.id, appId: chat.appId })
|
|
||||||
}
|
|
||||||
value={
|
|
||||||
(chat.title || "Untitled Chat") +
|
|
||||||
(snippet ? ` ${snippet.raw}` : "")
|
|
||||||
}
|
|
||||||
keywords={snippet ? [snippet.raw] : []}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>{chat.title || "Untitled Chat"}</span>
|
|
||||||
{snippet && (
|
|
||||||
<span className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
|
||||||
{snippet.before}
|
|
||||||
<mark className="bg-transparent underline decoration-2 decoration-primary">
|
|
||||||
{snippet.match}
|
|
||||||
</mark>
|
|
||||||
{snippet.after}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</CommandDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
|
|
||||||
interface CommunityCodeConsentDialogProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onAccept: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CommunityCodeConsentDialog: React.FC<
|
|
||||||
CommunityCodeConsentDialogProps
|
|
||||||
> = ({ isOpen, onAccept, onCancel }) => {
|
|
||||||
return (
|
|
||||||
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Community Code Notice</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription className="space-y-3">
|
|
||||||
<p>
|
|
||||||
This code was created by a Dyad community member, not our core
|
|
||||||
team.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Community code can be very helpful, but since it's built
|
|
||||||
independently, it may have bugs, security risks, or could cause
|
|
||||||
issues with your system. We can't provide official support if
|
|
||||||
problems occur.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
We recommend reviewing the code on GitHub first. Only proceed if
|
|
||||||
you're comfortable with these risks.
|
|
||||||
</p>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={onAccept}>Accept</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
interface ConfirmationDialogProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
confirmText?: string;
|
|
||||||
cancelText?: string;
|
|
||||||
confirmButtonClass?: string;
|
|
||||||
onConfirm: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ConfirmationDialog({
|
|
||||||
isOpen,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
confirmText = "Confirm",
|
|
||||||
cancelText = "Cancel",
|
|
||||||
confirmButtonClass = "bg-red-600 hover:bg-red-700 focus:ring-red-500",
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
}: ConfirmationDialogProps) {
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
|
||||||
<div className="flex min-h-screen items-center justify-center p-4 text-center sm:p-0">
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
|
||||||
onClick={onCancel}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
|
||||||
<div className="bg-white dark:bg-gray-800 px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
|
||||||
<div className="sm:flex sm:items-start">
|
|
||||||
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
|
||||||
<svg
|
|
||||||
className="h-6 w-6 text-red-600"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
|
|
||||||
<h3 className="text-lg font-medium leading-6 text-gray-900 dark:text-white">
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
<div className="mt-2">
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{message}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-700 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`inline-flex w-full justify-center rounded-md border border-transparent px-4 py-2 text-base font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm ${confirmButtonClass}`}
|
|
||||||
onClick={onConfirm}
|
|
||||||
>
|
|
||||||
{confirmText}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mt-3 inline-flex w-full justify-center rounded-md border border-gray-300 bg-white dark:bg-gray-600 dark:border-gray-500 dark:text-gray-200 px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:mt-0 sm:w-auto sm:text-sm"
|
|
||||||
onClick={onCancel}
|
|
||||||
>
|
|
||||||
{cancelText}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
|
|
||||||
import { InfoIcon, Settings2, Trash2 } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "./ui/tooltip";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import { useContextPaths } from "@/hooks/useContextPaths";
|
|
||||||
import type { ContextPathResult } from "@/lib/schemas";
|
|
||||||
|
|
||||||
export function ContextFilesPicker() {
|
|
||||||
const { settings } = useSettings();
|
|
||||||
const {
|
|
||||||
contextPaths,
|
|
||||||
smartContextAutoIncludes,
|
|
||||||
excludePaths,
|
|
||||||
updateContextPaths,
|
|
||||||
updateSmartContextAutoIncludes,
|
|
||||||
updateExcludePaths,
|
|
||||||
} = useContextPaths();
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [newPath, setNewPath] = useState("");
|
|
||||||
const [newAutoIncludePath, setNewAutoIncludePath] = useState("");
|
|
||||||
const [newExcludePath, setNewExcludePath] = useState("");
|
|
||||||
|
|
||||||
const addPath = () => {
|
|
||||||
if (
|
|
||||||
newPath.trim() === "" ||
|
|
||||||
contextPaths.find((p: ContextPathResult) => p.globPath === newPath)
|
|
||||||
) {
|
|
||||||
setNewPath("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newPaths = [
|
|
||||||
...contextPaths.map(({ globPath }: ContextPathResult) => ({ globPath })),
|
|
||||||
{
|
|
||||||
globPath: newPath,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
updateContextPaths(newPaths);
|
|
||||||
setNewPath("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const removePath = (pathToRemove: string) => {
|
|
||||||
const newPaths = contextPaths
|
|
||||||
.filter((p: ContextPathResult) => p.globPath !== pathToRemove)
|
|
||||||
.map(({ globPath }: ContextPathResult) => ({ globPath }));
|
|
||||||
updateContextPaths(newPaths);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addAutoIncludePath = () => {
|
|
||||||
if (
|
|
||||||
newAutoIncludePath.trim() === "" ||
|
|
||||||
smartContextAutoIncludes.find(
|
|
||||||
(p: ContextPathResult) => p.globPath === newAutoIncludePath,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
setNewAutoIncludePath("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newPaths = [
|
|
||||||
...smartContextAutoIncludes.map(({ globPath }: ContextPathResult) => ({
|
|
||||||
globPath,
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
globPath: newAutoIncludePath,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
updateSmartContextAutoIncludes(newPaths);
|
|
||||||
setNewAutoIncludePath("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeAutoIncludePath = (pathToRemove: string) => {
|
|
||||||
const newPaths = smartContextAutoIncludes
|
|
||||||
.filter((p: ContextPathResult) => p.globPath !== pathToRemove)
|
|
||||||
.map(({ globPath }: ContextPathResult) => ({ globPath }));
|
|
||||||
updateSmartContextAutoIncludes(newPaths);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addExcludePath = () => {
|
|
||||||
if (
|
|
||||||
newExcludePath.trim() === "" ||
|
|
||||||
excludePaths.find((p: ContextPathResult) => p.globPath === newExcludePath)
|
|
||||||
) {
|
|
||||||
setNewExcludePath("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newPaths = [
|
|
||||||
...excludePaths.map(({ globPath }: ContextPathResult) => ({ globPath })),
|
|
||||||
{
|
|
||||||
globPath: newExcludePath,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
updateExcludePaths(newPaths);
|
|
||||||
setNewExcludePath("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeExcludePath = (pathToRemove: string) => {
|
|
||||||
const newPaths = excludePaths
|
|
||||||
.filter((p: ContextPathResult) => p.globPath !== pathToRemove)
|
|
||||||
.map(({ globPath }: ContextPathResult) => ({ globPath }));
|
|
||||||
updateExcludePaths(newPaths);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSmartContextEnabled =
|
|
||||||
settings?.enableDyadPro && settings?.enableProSmartFilesContextMode;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="has-[>svg]:px-2"
|
|
||||||
size="sm"
|
|
||||||
data-testid="codebase-context-button"
|
|
||||||
>
|
|
||||||
<Settings2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Codebase Context</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<PopoverContent
|
|
||||||
className="w-96 max-h-[80vh] overflow-y-auto"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<div className="relative space-y-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium">Codebase Context</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="flex items-center gap-1 cursor-help">
|
|
||||||
Select the files to use as context.{" "}
|
|
||||||
<InfoIcon className="size-4" />
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="max-w-[300px]">
|
|
||||||
{isSmartContextEnabled ? (
|
|
||||||
<p>
|
|
||||||
With Smart Context, Dyad uses the most relevant files as
|
|
||||||
context.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p>By default, Dyad uses your whole codebase.</p>
|
|
||||||
)}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full max-w-sm items-center space-x-2">
|
|
||||||
<Input
|
|
||||||
data-testid="manual-context-files-input"
|
|
||||||
type="text"
|
|
||||||
placeholder="src/**/*.tsx"
|
|
||||||
value={newPath}
|
|
||||||
onChange={(e) => setNewPath(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
addPath();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
onClick={addPath}
|
|
||||||
data-testid="manual-context-files-add-button"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TooltipProvider>
|
|
||||||
{contextPaths.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{contextPaths.map((p: ContextPathResult) => (
|
|
||||||
<div
|
|
||||||
key={p.globPath}
|
|
||||||
className="flex items-center justify-between gap-2 rounded-md border p-2"
|
|
||||||
>
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="truncate font-mono text-sm">
|
|
||||||
{p.globPath}
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{p.globPath}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{p.files} files, ~{p.tokens} tokens
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => removePath(p.globPath)}
|
|
||||||
data-testid="manual-context-files-remove-button"
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-md border border-dashed p-4 text-center">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{isSmartContextEnabled
|
|
||||||
? "Dyad will use Smart Context to automatically find the most relevant files to use as context."
|
|
||||||
: "Dyad will use the entire codebase as context."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
<div className="pt-2">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium">Exclude Paths</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="flex items-center gap-1 cursor-help">
|
|
||||||
These files will be excluded from the context.{" "}
|
|
||||||
<InfoIcon className="ml-2 size-4" />
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="max-w-[300px]">
|
|
||||||
<p>
|
|
||||||
Exclude paths take precedence - files that match both
|
|
||||||
include and exclude patterns will be excluded.
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full max-w-sm items-center space-x-2 mt-4">
|
|
||||||
<Input
|
|
||||||
data-testid="exclude-context-files-input"
|
|
||||||
type="text"
|
|
||||||
placeholder="node_modules/**/*"
|
|
||||||
value={newExcludePath}
|
|
||||||
onChange={(e) => setNewExcludePath(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
addExcludePath();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
onClick={addExcludePath}
|
|
||||||
data-testid="exclude-context-files-add-button"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TooltipProvider>
|
|
||||||
{excludePaths.length > 0 && (
|
|
||||||
<div className="space-y-2 mt-4">
|
|
||||||
{excludePaths.map((p: ContextPathResult) => (
|
|
||||||
<div
|
|
||||||
key={p.globPath}
|
|
||||||
className="flex items-center justify-between gap-2 rounded-md border p-2 border-red-200"
|
|
||||||
>
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="truncate font-mono text-sm text-red-600">
|
|
||||||
{p.globPath}
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{p.globPath}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{p.files} files, ~{p.tokens} tokens
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => removeExcludePath(p.globPath)}
|
|
||||||
data-testid="exclude-context-files-remove-button"
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isSmartContextEnabled && (
|
|
||||||
<div className="pt-2">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium">Smart Context Auto-includes</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="flex items-center gap-1 cursor-help">
|
|
||||||
These files will always be included in the context.{" "}
|
|
||||||
<InfoIcon className="ml-2 size-4" />
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="max-w-[300px]">
|
|
||||||
<p>
|
|
||||||
Auto-include files are always included in the context
|
|
||||||
in addition to the files selected as relevant by Smart
|
|
||||||
Context.
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full max-w-sm items-center space-x-2 mt-4">
|
|
||||||
<Input
|
|
||||||
data-testid="auto-include-context-files-input"
|
|
||||||
type="text"
|
|
||||||
placeholder="src/**/*.config.ts"
|
|
||||||
value={newAutoIncludePath}
|
|
||||||
onChange={(e) => setNewAutoIncludePath(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
addAutoIncludePath();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
onClick={addAutoIncludePath}
|
|
||||||
data-testid="auto-include-context-files-add-button"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TooltipProvider>
|
|
||||||
{smartContextAutoIncludes.length > 0 && (
|
|
||||||
<div className="space-y-2 mt-4">
|
|
||||||
{smartContextAutoIncludes.map((p: ContextPathResult) => (
|
|
||||||
<div
|
|
||||||
key={p.globPath}
|
|
||||||
className="flex items-center justify-between gap-2 rounded-md border p-2"
|
|
||||||
>
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="truncate font-mono text-sm">
|
|
||||||
{p.globPath}
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{p.globPath}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{p.files} files, ~{p.tokens} tokens
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => removeAutoIncludePath(p.globPath)}
|
|
||||||
data-testid="auto-include-context-files-remove-button"
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { Copy, Check } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
interface CopyErrorMessageProps {
|
|
||||||
errorMessage: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CopyErrorMessage = ({
|
|
||||||
errorMessage,
|
|
||||||
className = "",
|
|
||||||
}: CopyErrorMessageProps) => {
|
|
||||||
const [isCopied, setIsCopied] = useState(false);
|
|
||||||
|
|
||||||
const handleCopy = async (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(errorMessage);
|
|
||||||
setIsCopied(true);
|
|
||||||
setTimeout(() => setIsCopied(false), 2000);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to copy error message:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={handleCopy}
|
|
||||||
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${
|
|
||||||
isCopied
|
|
||||||
? "bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300"
|
|
||||||
: "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600"
|
|
||||||
} ${className}`}
|
|
||||||
title={isCopied ? "Copied!" : "Copy error message"}
|
|
||||||
>
|
|
||||||
{isCopied ? (
|
|
||||||
<>
|
|
||||||
<Check size={14} />
|
|
||||||
<span>Copied</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Copy size={14} />
|
|
||||||
<span>Copy</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { useCreateApp } from "@/hooks/useCreateApp";
|
|
||||||
import { useCheckName } from "@/hooks/useCheckName";
|
|
||||||
import { useSetAtom } from "jotai";
|
|
||||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
|
||||||
import { NEON_TEMPLATE_IDS, Template } from "@/shared/templates";
|
|
||||||
|
|
||||||
import { useRouter } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { neonTemplateHook } from "@/client_logic/template_hook";
|
|
||||||
import { showError } from "@/lib/toast";
|
|
||||||
|
|
||||||
interface CreateAppDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
template: Template | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateAppDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
template,
|
|
||||||
}: CreateAppDialogProps) {
|
|
||||||
const setSelectedAppId = useSetAtom(selectedAppIdAtom);
|
|
||||||
const [appName, setAppName] = useState("");
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const { createApp } = useCreateApp();
|
|
||||||
const { data: nameCheckResult } = useCheckName(appName);
|
|
||||||
const router = useRouter();
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!appName.trim()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nameCheckResult?.exists) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
const result = await createApp({ name: appName.trim() });
|
|
||||||
if (template && NEON_TEMPLATE_IDS.has(template.id)) {
|
|
||||||
await neonTemplateHook({
|
|
||||||
appId: result.app.id,
|
|
||||||
appName: result.app.name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setSelectedAppId(result.app.id);
|
|
||||||
// Navigate to the new app's first chat
|
|
||||||
router.navigate({
|
|
||||||
to: "/chat",
|
|
||||||
search: { id: result.chatId },
|
|
||||||
});
|
|
||||||
setAppName("");
|
|
||||||
onOpenChange(false);
|
|
||||||
} catch (error) {
|
|
||||||
showError(error as any);
|
|
||||||
// Error is already handled by createApp hook or shown above
|
|
||||||
console.error("Error creating app:", error);
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isNameValid = appName.trim().length > 0;
|
|
||||||
const nameExists = nameCheckResult?.exists;
|
|
||||||
const canSubmit = isNameValid && !nameExists && !isSubmitting;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create New App</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{`Create a new app using the ${template?.title} template.`}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="grid gap-4 py-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="appName">App Name</Label>
|
|
||||||
<Input
|
|
||||||
id="appName"
|
|
||||||
value={appName}
|
|
||||||
onChange={(e) => setAppName(e.target.value)}
|
|
||||||
placeholder="Enter app name..."
|
|
||||||
className={nameExists ? "border-red-500" : ""}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
{nameExists && (
|
|
||||||
<p className="text-sm text-red-500">
|
|
||||||
An app with this name already exists
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={!canSubmit}
|
|
||||||
className="bg-indigo-600 hover:bg-indigo-700"
|
|
||||||
>
|
|
||||||
{isSubmitting && (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
)}
|
|
||||||
{isSubmitting ? "Creating..." : "Create App"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { showError, showSuccess } from "@/lib/toast";
|
|
||||||
|
|
||||||
interface CreateCustomModelDialogProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSuccess: () => void;
|
|
||||||
providerId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateCustomModelDialog({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onSuccess,
|
|
||||||
providerId,
|
|
||||||
}: CreateCustomModelDialogProps) {
|
|
||||||
const [apiName, setApiName] = useState("");
|
|
||||||
const [displayName, setDisplayName] = useState("");
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [maxOutputTokens, setMaxOutputTokens] = useState<string>("");
|
|
||||||
const [contextWindow, setContextWindow] = useState<string>("");
|
|
||||||
|
|
||||||
const ipcClient = IpcClient.getInstance();
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const params = {
|
|
||||||
apiName,
|
|
||||||
displayName,
|
|
||||||
providerId,
|
|
||||||
description: description || undefined,
|
|
||||||
maxOutputTokens: maxOutputTokens
|
|
||||||
? parseInt(maxOutputTokens, 10)
|
|
||||||
: undefined,
|
|
||||||
contextWindow: contextWindow ? parseInt(contextWindow, 10) : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!params.apiName) throw new Error("Model API name is required");
|
|
||||||
if (!params.displayName)
|
|
||||||
throw new Error("Model display name is required");
|
|
||||||
if (maxOutputTokens && isNaN(params.maxOutputTokens ?? NaN))
|
|
||||||
throw new Error("Max Output Tokens must be a valid number");
|
|
||||||
if (contextWindow && isNaN(params.contextWindow ?? NaN))
|
|
||||||
throw new Error("Context Window must be a valid number");
|
|
||||||
|
|
||||||
await ipcClient.createCustomLanguageModel(params);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
showSuccess("Custom model created successfully!");
|
|
||||||
resetForm();
|
|
||||||
onSuccess(); // Refetch or update UI
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
showError(error);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setApiName("");
|
|
||||||
setDisplayName("");
|
|
||||||
setDescription("");
|
|
||||||
setMaxOutputTokens("");
|
|
||||||
setContextWindow("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
mutation.mutate();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (!mutation.isPending) {
|
|
||||||
resetForm();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
|
||||||
<DialogContent className="sm:max-w-[525px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Add Custom Model</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Configure a new language model for the selected provider.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="grid gap-4 py-4">
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="model-id" className="text-right">
|
|
||||||
Model ID*
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="model-id"
|
|
||||||
value={apiName}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setApiName(e.target.value)
|
|
||||||
}
|
|
||||||
className="col-span-3"
|
|
||||||
placeholder="This must match the model expected by the API"
|
|
||||||
required
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="model-name" className="text-right">
|
|
||||||
Name*
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="model-name"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setDisplayName(e.target.value)
|
|
||||||
}
|
|
||||||
className="col-span-3"
|
|
||||||
placeholder="Human-friendly name for the model"
|
|
||||||
required
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="description" className="text-right">
|
|
||||||
Description
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setDescription(e.target.value)
|
|
||||||
}
|
|
||||||
className="col-span-3"
|
|
||||||
placeholder="Optional: Describe the model's capabilities"
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="max-output-tokens" className="text-right">
|
|
||||||
Max Output Tokens
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="max-output-tokens"
|
|
||||||
type="number"
|
|
||||||
value={maxOutputTokens}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setMaxOutputTokens(e.target.value)
|
|
||||||
}
|
|
||||||
className="col-span-3"
|
|
||||||
placeholder="Optional: e.g., 4096"
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="context-window" className="text-right">
|
|
||||||
Context Window
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="context-window"
|
|
||||||
type="number"
|
|
||||||
value={contextWindow}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setContextWindow(e.target.value)
|
|
||||||
}
|
|
||||||
className="col-span-3"
|
|
||||||
placeholder="Optional: e.g., 8192"
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={mutation.isPending}>
|
|
||||||
{mutation.isPending ? "Adding..." : "Add Model"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { useCustomLanguageModelProvider } from "@/hooks/useCustomLanguageModelProvider";
|
|
||||||
import type { LanguageModelProvider } from "@/ipc/ipc_types";
|
|
||||||
|
|
||||||
interface CreateCustomProviderDialogProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSuccess: () => void;
|
|
||||||
editingProvider?: LanguageModelProvider | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateCustomProviderDialog({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onSuccess,
|
|
||||||
editingProvider = null,
|
|
||||||
}: CreateCustomProviderDialogProps) {
|
|
||||||
const [id, setId] = useState("");
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [apiBaseUrl, setApiBaseUrl] = useState("");
|
|
||||||
const [envVarName, setEnvVarName] = useState("");
|
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
|
||||||
const isEditMode = Boolean(editingProvider);
|
|
||||||
|
|
||||||
const { createProvider, editProvider, isCreating, isEditing, error } =
|
|
||||||
useCustomLanguageModelProvider();
|
|
||||||
// Load provider data when editing
|
|
||||||
useEffect(() => {
|
|
||||||
if (editingProvider && isOpen) {
|
|
||||||
const cleanId = editingProvider.id?.startsWith("custom::")
|
|
||||||
? editingProvider.id.replace("custom::", "")
|
|
||||||
: editingProvider.id || "";
|
|
||||||
setId(cleanId);
|
|
||||||
setName(editingProvider.name || "");
|
|
||||||
setApiBaseUrl(editingProvider.apiBaseUrl || "");
|
|
||||||
setEnvVarName(editingProvider.envVarName || "");
|
|
||||||
} else if (!isOpen) {
|
|
||||||
// Reset form when dialog closes
|
|
||||||
setId("");
|
|
||||||
setName("");
|
|
||||||
setApiBaseUrl("");
|
|
||||||
setEnvVarName("");
|
|
||||||
setErrorMessage("");
|
|
||||||
}
|
|
||||||
}, [editingProvider, isOpen]);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setErrorMessage("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isEditMode && editingProvider) {
|
|
||||||
const cleanId = editingProvider.id?.startsWith("custom::")
|
|
||||||
? editingProvider.id.replace("custom::", "")
|
|
||||||
: editingProvider.id || "";
|
|
||||||
await editProvider({
|
|
||||||
id: cleanId,
|
|
||||||
name: name.trim(),
|
|
||||||
apiBaseUrl: apiBaseUrl.trim(),
|
|
||||||
envVarName: envVarName.trim() || undefined,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await createProvider({
|
|
||||||
id: id.trim(),
|
|
||||||
name: name.trim(),
|
|
||||||
apiBaseUrl: apiBaseUrl.trim(),
|
|
||||||
envVarName: envVarName.trim() || undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset form
|
|
||||||
setId("");
|
|
||||||
setName("");
|
|
||||||
setApiBaseUrl("");
|
|
||||||
setEnvVarName("");
|
|
||||||
|
|
||||||
onSuccess();
|
|
||||||
} catch (error) {
|
|
||||||
setErrorMessage(
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: `Failed to ${isEditMode ? "edit" : "create"} custom provider`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (!isCreating && !isEditing) {
|
|
||||||
setErrorMessage("");
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const isLoading = isCreating || isEditing;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{isEditMode ? "Edit Custom Provider" : "Add Custom Provider"}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{isEditMode
|
|
||||||
? "Update your custom language model provider configuration."
|
|
||||||
: "Connect to a custom language model provider API."}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 pt-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="id">Provider ID</Label>
|
|
||||||
<Input
|
|
||||||
id="id"
|
|
||||||
value={id}
|
|
||||||
onChange={(e) => setId(e.target.value)}
|
|
||||||
placeholder="E.g., my-provider"
|
|
||||||
required
|
|
||||||
disabled={isLoading || isEditMode}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
A unique identifier for this provider (no spaces).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">Display Name</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="E.g., My Provider"
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
The name that will be displayed in the UI.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="apiBaseUrl">API Base URL</Label>
|
|
||||||
<Input
|
|
||||||
id="apiBaseUrl"
|
|
||||||
value={apiBaseUrl}
|
|
||||||
onChange={(e) => setApiBaseUrl(e.target.value)}
|
|
||||||
placeholder="E.g., https://api.example.com/v1"
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
The base URL for the API endpoint.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="envVarName">Environment Variable (Optional)</Label>
|
|
||||||
<Input
|
|
||||||
id="envVarName"
|
|
||||||
value={envVarName}
|
|
||||||
onChange={(e) => setEnvVarName(e.target.value)}
|
|
||||||
placeholder="E.g., MY_PROVIDER_API_KEY"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Environment variable name for the API key.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(errorMessage || error) && (
|
|
||||||
<div className="text-sm text-red-500">
|
|
||||||
{errorMessage ||
|
|
||||||
(error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to create custom provider")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isLoading}>
|
|
||||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
{isLoading
|
|
||||||
? isEditMode
|
|
||||||
? "Updating..."
|
|
||||||
: "Adding..."
|
|
||||||
: isEditMode
|
|
||||||
? "Update Provider"
|
|
||||||
: "Add Provider"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { Plus, Save, Edit2 } from "lucide-react";
|
|
||||||
|
|
||||||
interface CreateOrEditPromptDialogProps {
|
|
||||||
mode: "create" | "edit";
|
|
||||||
prompt?: {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
description: string | null;
|
|
||||||
content: string;
|
|
||||||
};
|
|
||||||
onCreatePrompt?: (prompt: {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
content: string;
|
|
||||||
}) => Promise<any>;
|
|
||||||
onUpdatePrompt?: (prompt: {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
content: string;
|
|
||||||
}) => Promise<any>;
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
prefillData?: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
content: string;
|
|
||||||
};
|
|
||||||
isOpen?: boolean;
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateOrEditPromptDialog({
|
|
||||||
mode,
|
|
||||||
prompt,
|
|
||||||
onCreatePrompt,
|
|
||||||
onUpdatePrompt,
|
|
||||||
trigger,
|
|
||||||
prefillData,
|
|
||||||
isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
}: CreateOrEditPromptDialogProps) {
|
|
||||||
const [internalOpen, setInternalOpen] = useState(false);
|
|
||||||
const open = isOpen !== undefined ? isOpen : internalOpen;
|
|
||||||
const setOpen = onOpenChange || setInternalOpen;
|
|
||||||
|
|
||||||
const [draft, setDraft] = useState({
|
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
content: "",
|
|
||||||
});
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
|
|
||||||
// Auto-resize textarea function
|
|
||||||
const adjustTextareaHeight = () => {
|
|
||||||
const textarea = textareaRef.current;
|
|
||||||
if (textarea) {
|
|
||||||
// Store current height to avoid flicker
|
|
||||||
const currentHeight = textarea.style.height;
|
|
||||||
textarea.style.height = "auto";
|
|
||||||
const scrollHeight = textarea.scrollHeight;
|
|
||||||
const maxHeight = window.innerHeight * 0.6 - 100; // 60vh in pixels
|
|
||||||
const minHeight = 150; // 150px minimum
|
|
||||||
const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
|
|
||||||
|
|
||||||
// Only update if height actually changed to reduce reflows
|
|
||||||
if (`${newHeight}px` !== currentHeight) {
|
|
||||||
textarea.style.height = `${newHeight}px`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize draft with prompt data when editing or prefill data
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode === "edit" && prompt) {
|
|
||||||
setDraft({
|
|
||||||
title: prompt.title,
|
|
||||||
description: prompt.description || "",
|
|
||||||
content: prompt.content,
|
|
||||||
});
|
|
||||||
} else if (prefillData) {
|
|
||||||
setDraft({
|
|
||||||
title: prefillData.title,
|
|
||||||
description: prefillData.description,
|
|
||||||
content: prefillData.content,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setDraft({ title: "", description: "", content: "" });
|
|
||||||
}
|
|
||||||
}, [mode, prompt, prefillData, open]);
|
|
||||||
|
|
||||||
// Auto-resize textarea when content changes
|
|
||||||
useEffect(() => {
|
|
||||||
adjustTextareaHeight();
|
|
||||||
}, [draft.content]);
|
|
||||||
|
|
||||||
// Trigger resize when dialog opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
// Small delay to ensure the dialog is fully rendered
|
|
||||||
setTimeout(adjustTextareaHeight, 0);
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const resetDraft = () => {
|
|
||||||
if (mode === "edit" && prompt) {
|
|
||||||
setDraft({
|
|
||||||
title: prompt.title,
|
|
||||||
description: prompt.description || "",
|
|
||||||
content: prompt.content,
|
|
||||||
});
|
|
||||||
} else if (prefillData) {
|
|
||||||
setDraft({
|
|
||||||
title: prefillData.title,
|
|
||||||
description: prefillData.description,
|
|
||||||
content: prefillData.content,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setDraft({ title: "", description: "", content: "" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSave = async () => {
|
|
||||||
if (!draft.title.trim() || !draft.content.trim()) return;
|
|
||||||
|
|
||||||
if (mode === "create" && onCreatePrompt) {
|
|
||||||
await onCreatePrompt({
|
|
||||||
title: draft.title.trim(),
|
|
||||||
description: draft.description.trim() || undefined,
|
|
||||||
content: draft.content,
|
|
||||||
});
|
|
||||||
} else if (mode === "edit" && onUpdatePrompt && prompt) {
|
|
||||||
await onUpdatePrompt({
|
|
||||||
id: prompt.id,
|
|
||||||
title: draft.title.trim(),
|
|
||||||
description: draft.description.trim() || undefined,
|
|
||||||
content: draft.content,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
resetDraft();
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
{trigger ? (
|
|
||||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
|
||||||
) : mode === "create" ? (
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button>
|
|
||||||
<Plus className="mr-2 h-4 w-4" /> New Prompt
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
) : (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
data-testid="edit-prompt-button"
|
|
||||||
>
|
|
||||||
<Edit2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Edit prompt</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{mode === "create" ? "Create New Prompt" : "Edit Prompt"}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{mode === "create"
|
|
||||||
? "Create a new prompt template for your library."
|
|
||||||
: "Edit your prompt template."}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Input
|
|
||||||
placeholder="Title"
|
|
||||||
value={draft.title}
|
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, title: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder="Description (optional)"
|
|
||||||
value={draft.description}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((d) => ({ ...d, description: e.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
placeholder="Content"
|
|
||||||
value={draft.content}
|
|
||||||
onChange={(e) => {
|
|
||||||
setDraft((d) => ({ ...d, content: e.target.value }));
|
|
||||||
// Use requestAnimationFrame for smoother updates
|
|
||||||
requestAnimationFrame(adjustTextareaHeight);
|
|
||||||
}}
|
|
||||||
className="resize-none overflow-y-auto"
|
|
||||||
style={{ minHeight: "150px" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={handleCancel}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={onSave}
|
|
||||||
disabled={!draft.title.trim() || !draft.content.trim()}
|
|
||||||
>
|
|
||||||
<Save className="mr-2 h-4 w-4" /> Save
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backward compatibility wrapper for create mode
|
|
||||||
export function CreatePromptDialog({
|
|
||||||
onCreatePrompt,
|
|
||||||
prefillData,
|
|
||||||
isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
}: {
|
|
||||||
onCreatePrompt: (prompt: {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
content: string;
|
|
||||||
}) => Promise<any>;
|
|
||||||
prefillData?: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
content: string;
|
|
||||||
};
|
|
||||||
isOpen?: boolean;
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<CreateOrEditPromptDialog
|
|
||||||
mode="create"
|
|
||||||
onCreatePrompt={onCreatePrompt}
|
|
||||||
prefillData={prefillData}
|
|
||||||
isOpen={isOpen}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { X, Copy, Check } from "lucide-react";
|
|
||||||
|
|
||||||
interface CustomErrorToastProps {
|
|
||||||
message: string;
|
|
||||||
toastId: string | number;
|
|
||||||
copied?: boolean;
|
|
||||||
onCopy?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CustomErrorToast({
|
|
||||||
message,
|
|
||||||
toastId,
|
|
||||||
copied = false,
|
|
||||||
onCopy,
|
|
||||||
}: CustomErrorToastProps) {
|
|
||||||
const handleClose = () => {
|
|
||||||
toast.dismiss(toastId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopy = () => {
|
|
||||||
if (onCopy) {
|
|
||||||
onCopy();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative bg-red-50/95 backdrop-blur-sm border border-red-200 rounded-xl shadow-lg min-w-[400px] max-w-[500px] overflow-hidden">
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex items-start">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center mb-3">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="w-5 h-5 bg-gradient-to-br from-red-400 to-red-500 rounded-full flex items-center justify-center shadow-sm">
|
|
||||||
<X className="w-3 h-3 text-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h3 className="ml-3 text-sm font-medium text-red-900">Error</h3>
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<div className="flex items-center space-x-1.5 ml-auto">
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleCopy();
|
|
||||||
}}
|
|
||||||
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-100/70 rounded-lg transition-all duration-150"
|
|
||||||
title="Copy to clipboard"
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<Check className="w-4 h-4 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<Copy className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleClose();
|
|
||||||
}}
|
|
||||||
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-100/70 rounded-lg transition-all duration-150"
|
|
||||||
title="Close"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-red-800 leading-relaxed whitespace-pre-wrap bg-red-100/50 backdrop-blur-sm p-3 rounded-lg border border-red-200/50">
|
|
||||||
{message}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Trash2 } from "lucide-react";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
|
|
||||||
interface DeleteConfirmationDialogProps {
|
|
||||||
itemName: string;
|
|
||||||
itemType?: string;
|
|
||||||
onDelete: () => void | Promise<void>;
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeleteConfirmationDialog({
|
|
||||||
itemName,
|
|
||||||
itemType = "item",
|
|
||||||
onDelete,
|
|
||||||
trigger,
|
|
||||||
}: DeleteConfirmationDialogProps) {
|
|
||||||
return (
|
|
||||||
<AlertDialog>
|
|
||||||
{trigger ? (
|
|
||||||
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
|
|
||||||
) : (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
data-testid="delete-prompt-button"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Delete {itemType.toLowerCase()}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete {itemType}</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Are you sure you want to delete "{itemName}"? This action cannot be
|
|
||||||
undone.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={onDelete}>Delete</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { CheckCircle, Sparkles } from "lucide-react";
|
|
||||||
|
|
||||||
interface DyadProSuccessDialogProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DyadProSuccessDialog({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
}: DyadProSuccessDialogProps) {
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
||||||
<DialogContent className="max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2 text-xl">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
|
|
||||||
<CheckCircle className="h-6 w-6 text-green-600 dark:text-green-400" />
|
|
||||||
</div>
|
|
||||||
<span>Dyad Pro Enabled</span>
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="py-4">
|
|
||||||
<p className="mb-4 text-base">
|
|
||||||
Congrats! Dyad Pro is now enabled in the app.
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Sparkles className="h-5 w-5 text-indigo-500" />
|
|
||||||
<p className="text-sm">You have access to leading AI models.</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
You can click the Pro button at the top to access the settings at
|
|
||||||
any time.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<DialogFooter className="flex justify-end gap-2">
|
|
||||||
<Button onClick={onClose} variant="outline">
|
|
||||||
OK
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { showError, showSuccess } from "@/lib/toast";
|
|
||||||
|
|
||||||
interface Model {
|
|
||||||
apiName: string;
|
|
||||||
displayName: string;
|
|
||||||
description?: string;
|
|
||||||
maxOutputTokens?: number;
|
|
||||||
contextWindow?: number;
|
|
||||||
type: "cloud" | "custom";
|
|
||||||
tag?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EditCustomModelDialogProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSuccess: () => void;
|
|
||||||
providerId: string;
|
|
||||||
model: Model | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EditCustomModelDialog({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onSuccess,
|
|
||||||
providerId,
|
|
||||||
model,
|
|
||||||
}: EditCustomModelDialogProps) {
|
|
||||||
const [apiName, setApiName] = useState("");
|
|
||||||
const [displayName, setDisplayName] = useState("");
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [maxOutputTokens, setMaxOutputTokens] = useState<string>("");
|
|
||||||
const [contextWindow, setContextWindow] = useState<string>("");
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
|
|
||||||
const ipcClient = IpcClient.getInstance();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (model) {
|
|
||||||
setApiName(model.apiName);
|
|
||||||
setDisplayName(model.displayName);
|
|
||||||
setDescription(model.description || "");
|
|
||||||
setMaxOutputTokens(model.maxOutputTokens?.toString() || "");
|
|
||||||
setContextWindow(model.contextWindow?.toString() || "");
|
|
||||||
}
|
|
||||||
}, [model]);
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
if (!model) throw new Error("No model to edit");
|
|
||||||
|
|
||||||
const newParams = {
|
|
||||||
apiName,
|
|
||||||
displayName,
|
|
||||||
providerId,
|
|
||||||
description: description || undefined,
|
|
||||||
maxOutputTokens: maxOutputTokens
|
|
||||||
? parseInt(maxOutputTokens, 10)
|
|
||||||
: undefined,
|
|
||||||
contextWindow: contextWindow ? parseInt(contextWindow, 10) : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!newParams.apiName) throw new Error("Model API name is required");
|
|
||||||
if (!newParams.displayName)
|
|
||||||
throw new Error("Model display name is required");
|
|
||||||
if (maxOutputTokens && isNaN(newParams.maxOutputTokens ?? NaN))
|
|
||||||
throw new Error("Max Output Tokens must be a valid number");
|
|
||||||
if (contextWindow && isNaN(newParams.contextWindow ?? NaN))
|
|
||||||
throw new Error("Context Window must be a valid number");
|
|
||||||
|
|
||||||
// First delete the old model
|
|
||||||
await ipcClient.deleteCustomModel({
|
|
||||||
providerId,
|
|
||||||
modelApiName: model.apiName,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Then create the new model
|
|
||||||
await ipcClient.createCustomLanguageModel(newParams);
|
|
||||||
},
|
|
||||||
onSuccess: async () => {
|
|
||||||
if (
|
|
||||||
settings?.selectedModel?.name === model?.apiName &&
|
|
||||||
settings?.selectedModel?.provider === providerId
|
|
||||||
) {
|
|
||||||
const newModel = {
|
|
||||||
...settings.selectedModel,
|
|
||||||
name: apiName,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await updateSettings({ selectedModel: newModel });
|
|
||||||
} catch {
|
|
||||||
showError("Failed to update settings");
|
|
||||||
return; // stop closing dialog
|
|
||||||
}
|
|
||||||
}
|
|
||||||
showSuccess("Custom model updated successfully!");
|
|
||||||
onSuccess();
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
showError(error);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
mutation.mutate();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (!mutation.isPending) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!model) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
|
||||||
<DialogContent className="sm:max-w-[525px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Edit Custom Model</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Modify the configuration of the selected language model.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="grid gap-4 py-4">
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="edit-model-id" className="text-right">
|
|
||||||
Model ID*
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-model-id"
|
|
||||||
value={apiName}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setApiName(e.target.value)
|
|
||||||
}
|
|
||||||
className="col-span-3"
|
|
||||||
placeholder="This must match the model expected by the API"
|
|
||||||
required
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="edit-model-name" className="text-right">
|
|
||||||
Name*
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-model-name"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setDisplayName(e.target.value)
|
|
||||||
}
|
|
||||||
className="col-span-3"
|
|
||||||
placeholder="Human-friendly name for the model"
|
|
||||||
required
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="edit-description" className="text-right">
|
|
||||||
Description
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setDescription(e.target.value)
|
|
||||||
}
|
|
||||||
className="col-span-3"
|
|
||||||
placeholder="Optional: Describe the model's capabilities"
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="edit-max-output-tokens" className="text-right">
|
|
||||||
Max Output Tokens
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-max-output-tokens"
|
|
||||||
type="number"
|
|
||||||
value={maxOutputTokens}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setMaxOutputTokens(e.target.value)
|
|
||||||
}
|
|
||||||
className="col-span-3"
|
|
||||||
placeholder="Optional: e.g., 4096"
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="edit-context-window" className="text-right">
|
|
||||||
Context Window
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-context-window"
|
|
||||||
type="number"
|
|
||||||
value={contextWindow}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setContextWindow(e.target.value)
|
|
||||||
}
|
|
||||||
className="col-span-3"
|
|
||||||
placeholder="Optional: e.g., 8192"
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={mutation.isPending}>
|
|
||||||
{mutation.isPending ? "Updating..." : "Update Model"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { LightbulbIcon } from "lucide-react";
|
|
||||||
import { ErrorComponentProps } from "@tanstack/react-router";
|
|
||||||
import { usePostHog } from "posthog-js/react";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
|
|
||||||
export function ErrorBoundary({ error }: ErrorComponentProps) {
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const posthog = usePostHog();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.error("An error occurred in the route:", error);
|
|
||||||
posthog.captureException(error);
|
|
||||||
}, [error]);
|
|
||||||
|
|
||||||
const handleReportBug = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
// Get system debug info
|
|
||||||
const debugInfo = await IpcClient.getInstance().getSystemDebugInfo();
|
|
||||||
|
|
||||||
// Create a formatted issue body with the debug info and error information
|
|
||||||
const issueBody = `
|
|
||||||
## Bug Description
|
|
||||||
<!-- Please describe the issue you're experiencing -->
|
|
||||||
|
|
||||||
## Steps to Reproduce
|
|
||||||
<!-- Please list the steps to reproduce the issue -->
|
|
||||||
|
|
||||||
## Expected Behavior
|
|
||||||
<!-- What did you expect to happen? -->
|
|
||||||
|
|
||||||
## Actual Behavior
|
|
||||||
<!-- What actually happened? -->
|
|
||||||
|
|
||||||
## Error Details
|
|
||||||
- Error Name: ${error?.name || "Unknown"}
|
|
||||||
- Error Message: ${error?.message || "Unknown"}
|
|
||||||
${error?.stack ? `\n\`\`\`\n${error.stack.slice(0, 1000)}\n\`\`\`` : ""}
|
|
||||||
|
|
||||||
## System Information
|
|
||||||
- Dyad Version: ${debugInfo.dyadVersion}
|
|
||||||
- Platform: ${debugInfo.platform}
|
|
||||||
- Architecture: ${debugInfo.architecture}
|
|
||||||
- Node Version: ${debugInfo.nodeVersion || "Not available"}
|
|
||||||
- PNPM Version: ${debugInfo.pnpmVersion || "Not available"}
|
|
||||||
- Node Path: ${debugInfo.nodePath || "Not available"}
|
|
||||||
- Telemetry ID: ${debugInfo.telemetryId || "Not available"}
|
|
||||||
|
|
||||||
## Logs
|
|
||||||
\`\`\`
|
|
||||||
${debugInfo.logs.slice(-3_500) || "No logs available"}
|
|
||||||
\`\`\`
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Create the GitHub issue URL with the pre-filled body
|
|
||||||
const encodedBody = encodeURIComponent(issueBody);
|
|
||||||
const encodedTitle = encodeURIComponent(
|
|
||||||
"[bug] Error in Dyad application",
|
|
||||||
);
|
|
||||||
const githubIssueUrl = `https://github.com/dyad-sh/dyad/issues/new?title=${encodedTitle}&labels=bug,filed-from-app,client-error&body=${encodedBody}`;
|
|
||||||
|
|
||||||
// Open the pre-filled GitHub issue page
|
|
||||||
await IpcClient.getInstance().openExternalUrl(githubIssueUrl);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to prepare bug report:", err);
|
|
||||||
// Fallback to opening the regular GitHub issue page
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
"https://github.com/dyad-sh/dyad/issues/new",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center h-screen p-6">
|
|
||||||
<div className="max-w-md w-full bg-background p-6 rounded-lg shadow-lg">
|
|
||||||
<h2 className="text-xl font-bold mb-4">
|
|
||||||
Sorry, that shouldn't have happened!
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p className="text-sm mb-3">There was an error loading the app...</p>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="bg-slate-100 dark:bg-slate-800 p-4 rounded-md mb-6">
|
|
||||||
<p className="text-sm mb-1">
|
|
||||||
<strong>Error name:</strong> {error.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm">
|
|
||||||
<strong>Error message:</strong> {error.message}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button onClick={handleReportBug} disabled={isLoading}>
|
|
||||||
{isLoading ? "Preparing report..." : "Report Bug"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md flex items-center gap-2">
|
|
||||||
<LightbulbIcon className="h-4 w-4 text-blue-700 dark:text-blue-400 flex-shrink-0" />
|
|
||||||
<p className="text-sm text-blue-700 dark:text-blue-400">
|
|
||||||
<strong>Tip:</strong> Try closing and re-opening Dyad as a temporary
|
|
||||||
workaround.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { AlertTriangle } from "lucide-react";
|
|
||||||
|
|
||||||
interface ForceCloseDialogProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
performanceData?: {
|
|
||||||
timestamp: number;
|
|
||||||
memoryUsageMB: number;
|
|
||||||
cpuUsagePercent?: number;
|
|
||||||
systemMemoryUsageMB?: number;
|
|
||||||
systemMemoryTotalMB?: number;
|
|
||||||
systemCpuPercent?: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ForceCloseDialog({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
performanceData,
|
|
||||||
}: ForceCloseDialogProps) {
|
|
||||||
const formatTimestamp = (timestamp: number) => {
|
|
||||||
return new Date(timestamp).toLocaleString();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
|
||||||
<AlertDialogContent className="max-w-2xl">
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<AlertTriangle className="h-5 w-5 text-yellow-500" />
|
|
||||||
<AlertDialogTitle>Force Close Detected</AlertDialogTitle>
|
|
||||||
</div>
|
|
||||||
<AlertDialogDescription asChild>
|
|
||||||
<div className="space-y-4 pt-2">
|
|
||||||
<div className="text-base">
|
|
||||||
The app was not closed properly the last time it was running.
|
|
||||||
This could indicate a crash or unexpected termination.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{performanceData && (
|
|
||||||
<div className="rounded-lg border bg-muted/50 p-4 space-y-3">
|
|
||||||
<div className="font-semibold text-sm text-foreground">
|
|
||||||
Last Known State:{" "}
|
|
||||||
<span className="font-normal text-muted-foreground">
|
|
||||||
{formatTimestamp(performanceData.timestamp)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
||||||
{/* Process Metrics */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="font-medium text-foreground">
|
|
||||||
Process Metrics
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">Memory:</span>
|
|
||||||
<span className="font-mono">
|
|
||||||
{performanceData.memoryUsageMB} MB
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{performanceData.cpuUsagePercent !== undefined && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">CPU:</span>
|
|
||||||
<span className="font-mono">
|
|
||||||
{performanceData.cpuUsagePercent}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* System Metrics */}
|
|
||||||
{(performanceData.systemMemoryUsageMB !== undefined ||
|
|
||||||
performanceData.systemCpuPercent !== undefined) && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="font-medium text-foreground">
|
|
||||||
System Metrics
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{performanceData.systemMemoryUsageMB !== undefined &&
|
|
||||||
performanceData.systemMemoryTotalMB !==
|
|
||||||
undefined && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
Memory:
|
|
||||||
</span>
|
|
||||||
<span className="font-mono">
|
|
||||||
{performanceData.systemMemoryUsageMB} /{" "}
|
|
||||||
{performanceData.systemMemoryTotalMB} MB
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{performanceData.systemCpuPercent !== undefined && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
CPU:
|
|
||||||
</span>
|
|
||||||
<span className="font-mono">
|
|
||||||
{performanceData.systemCpuPercent}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogAction onClick={onClose}>OK</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,940 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Github,
|
|
||||||
Clipboard,
|
|
||||||
Check,
|
|
||||||
AlertTriangle,
|
|
||||||
ChevronRight,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import { useLoadApp } from "@/hooks/useLoadApp";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
|
|
||||||
interface GitHubConnectorProps {
|
|
||||||
appId: number | null;
|
|
||||||
folderName: string;
|
|
||||||
expanded?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GitHubRepo {
|
|
||||||
name: string;
|
|
||||||
full_name: string;
|
|
||||||
private: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GitHubBranch {
|
|
||||||
name: string;
|
|
||||||
commit: { sha: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConnectedGitHubConnectorProps {
|
|
||||||
appId: number;
|
|
||||||
app: any;
|
|
||||||
refreshApp: () => void;
|
|
||||||
triggerAutoSync?: boolean;
|
|
||||||
onAutoSyncComplete?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UnconnectedGitHubConnectorProps {
|
|
||||||
appId: number | null;
|
|
||||||
folderName: string;
|
|
||||||
settings: any;
|
|
||||||
refreshSettings: () => void;
|
|
||||||
handleRepoSetupComplete: () => void;
|
|
||||||
expanded?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConnectedGitHubConnector({
|
|
||||||
appId,
|
|
||||||
app,
|
|
||||||
refreshApp,
|
|
||||||
triggerAutoSync,
|
|
||||||
onAutoSyncComplete,
|
|
||||||
}: ConnectedGitHubConnectorProps) {
|
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
|
||||||
const [syncError, setSyncError] = useState<string | null>(null);
|
|
||||||
const [syncSuccess, setSyncSuccess] = useState<boolean>(false);
|
|
||||||
const [showForceDialog, setShowForceDialog] = useState(false);
|
|
||||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
|
||||||
const [disconnectError, setDisconnectError] = useState<string | null>(null);
|
|
||||||
const autoSyncTriggeredRef = useRef(false);
|
|
||||||
|
|
||||||
const handleDisconnectRepo = async () => {
|
|
||||||
setIsDisconnecting(true);
|
|
||||||
setDisconnectError(null);
|
|
||||||
try {
|
|
||||||
await IpcClient.getInstance().disconnectGithubRepo(appId);
|
|
||||||
refreshApp();
|
|
||||||
} catch (err: any) {
|
|
||||||
setDisconnectError(err.message || "Failed to disconnect repository.");
|
|
||||||
} finally {
|
|
||||||
setIsDisconnecting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSyncToGithub = useCallback(
|
|
||||||
async (force: boolean = false) => {
|
|
||||||
setIsSyncing(true);
|
|
||||||
setSyncError(null);
|
|
||||||
setSyncSuccess(false);
|
|
||||||
setShowForceDialog(false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await IpcClient.getInstance().syncGithubRepo(
|
|
||||||
appId,
|
|
||||||
force,
|
|
||||||
);
|
|
||||||
if (result.success) {
|
|
||||||
setSyncSuccess(true);
|
|
||||||
} else {
|
|
||||||
setSyncError(result.error || "Failed to sync to GitHub.");
|
|
||||||
// If it's a push rejection error, show the force dialog
|
|
||||||
if (
|
|
||||||
result.error?.includes("rejected") ||
|
|
||||||
result.error?.includes("non-fast-forward")
|
|
||||||
) {
|
|
||||||
// Don't show force dialog immediately, let user see the error first
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setSyncError(err.message || "Failed to sync to GitHub.");
|
|
||||||
} finally {
|
|
||||||
setIsSyncing(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[appId],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Auto-sync when triggerAutoSync prop is true
|
|
||||||
useEffect(() => {
|
|
||||||
if (triggerAutoSync && !autoSyncTriggeredRef.current) {
|
|
||||||
autoSyncTriggeredRef.current = true;
|
|
||||||
handleSyncToGithub(false).finally(() => {
|
|
||||||
onAutoSyncComplete?.();
|
|
||||||
});
|
|
||||||
} else if (!triggerAutoSync) {
|
|
||||||
// Reset the ref when triggerAutoSync becomes false
|
|
||||||
autoSyncTriggeredRef.current = false;
|
|
||||||
}
|
|
||||||
}, [triggerAutoSync]); // Only depend on triggerAutoSync to avoid unnecessary re-runs
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full" data-testid="github-connected-repo">
|
|
||||||
<p>Connected to GitHub Repo:</p>
|
|
||||||
<a
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
`https://github.com/${app.githubOrg}/${app.githubRepo}`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{app.githubOrg}/{app.githubRepo}
|
|
||||||
</a>
|
|
||||||
{app.githubBranch && (
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1">
|
|
||||||
Branch: <span className="font-mono">{app.githubBranch}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="mt-2 flex gap-2">
|
|
||||||
<Button onClick={() => handleSyncToGithub(false)} disabled={isSyncing}>
|
|
||||||
{isSyncing ? (
|
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
className="animate-spin h-5 w-5 mr-2 inline"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
style={{ display: "inline" }}
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Syncing...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Sync to GitHub"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleDisconnectRepo}
|
|
||||||
disabled={isDisconnecting}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{isDisconnecting ? "Disconnecting..." : "Disconnect from repo"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{syncError && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<p className="text-red-600">
|
|
||||||
{syncError}{" "}
|
|
||||||
<a
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
"https://www.dyad.sh/docs/integrations/github#troubleshooting",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
See troubleshooting guide
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
{(syncError.includes("rejected") ||
|
|
||||||
syncError.includes("non-fast-forward")) && (
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowForceDialog(true)}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="mt-2 text-orange-600 border-orange-600 hover:bg-orange-50"
|
|
||||||
>
|
|
||||||
<AlertTriangle className="h-4 w-4 mr-2" />
|
|
||||||
Force Push (Dangerous)
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{syncSuccess && (
|
|
||||||
<p className="text-green-600 mt-2">Successfully pushed to GitHub!</p>
|
|
||||||
)}
|
|
||||||
{disconnectError && (
|
|
||||||
<p className="text-red-600 mt-2">{disconnectError}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Force Push Warning Dialog */}
|
|
||||||
<Dialog open={showForceDialog} onOpenChange={setShowForceDialog}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<AlertTriangle className="h-5 w-5 text-orange-500" />
|
|
||||||
Force Push Warning
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p>
|
|
||||||
You are about to perform a <strong>force push</strong> to your
|
|
||||||
GitHub repository.
|
|
||||||
</p>
|
|
||||||
<div className="bg-orange-50 dark:bg-orange-900/20 p-3 rounded-md border border-orange-200 dark:border-orange-800">
|
|
||||||
<p className="text-sm text-orange-800 dark:text-orange-200">
|
|
||||||
<strong>
|
|
||||||
This is dangerous and non-reversible and will:
|
|
||||||
</strong>
|
|
||||||
</p>
|
|
||||||
<ul className="text-sm text-orange-700 dark:text-orange-300 list-disc list-inside mt-2 space-y-1">
|
|
||||||
<li>Overwrite the remote repository history</li>
|
|
||||||
<li>
|
|
||||||
Permanently delete commits that exist on the remote but
|
|
||||||
not locally
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm">
|
|
||||||
Only proceed if you're certain this is what you want to do.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setShowForceDialog(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => handleSyncToGithub(true)}
|
|
||||||
disabled={isSyncing}
|
|
||||||
>
|
|
||||||
{isSyncing ? "Force Pushing..." : "Force Push"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UnconnectedGitHubConnector({
|
|
||||||
appId,
|
|
||||||
folderName,
|
|
||||||
settings,
|
|
||||||
refreshSettings,
|
|
||||||
handleRepoSetupComplete,
|
|
||||||
expanded,
|
|
||||||
}: UnconnectedGitHubConnectorProps) {
|
|
||||||
// --- Collapsible State ---
|
|
||||||
const [isExpanded, setIsExpanded] = useState(expanded || false);
|
|
||||||
|
|
||||||
// --- GitHub Device Flow State ---
|
|
||||||
const [githubUserCode, setGithubUserCode] = useState<string | null>(null);
|
|
||||||
const [githubVerificationUri, setGithubVerificationUri] = useState<
|
|
||||||
string | null
|
|
||||||
>(null);
|
|
||||||
const [githubError, setGithubError] = useState<string | null>(null);
|
|
||||||
const [isConnectingToGithub, setIsConnectingToGithub] = useState(false);
|
|
||||||
const [githubStatusMessage, setGithubStatusMessage] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [codeCopied, setCodeCopied] = useState(false);
|
|
||||||
|
|
||||||
// --- Repo Setup State ---
|
|
||||||
const [repoSetupMode, setRepoSetupMode] = useState<"create" | "existing">(
|
|
||||||
"create",
|
|
||||||
);
|
|
||||||
const [availableRepos, setAvailableRepos] = useState<GitHubRepo[]>([]);
|
|
||||||
const [isLoadingRepos, setIsLoadingRepos] = useState(false);
|
|
||||||
const [selectedRepo, setSelectedRepo] = useState<string>("");
|
|
||||||
const [availableBranches, setAvailableBranches] = useState<GitHubBranch[]>(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
|
||||||
const [selectedBranch, setSelectedBranch] = useState<string>("main");
|
|
||||||
const [branchInputMode, setBranchInputMode] = useState<"select" | "custom">(
|
|
||||||
"select",
|
|
||||||
);
|
|
||||||
const [customBranchName, setCustomBranchName] = useState<string>("");
|
|
||||||
|
|
||||||
// Create new repo state
|
|
||||||
const [repoName, setRepoName] = useState(folderName);
|
|
||||||
const [repoAvailable, setRepoAvailable] = useState<boolean | null>(null);
|
|
||||||
const [repoCheckError, setRepoCheckError] = useState<string | null>(null);
|
|
||||||
const [isCheckingRepo, setIsCheckingRepo] = useState(false);
|
|
||||||
const [isCreatingRepo, setIsCreatingRepo] = useState(false);
|
|
||||||
const [createRepoError, setCreateRepoError] = useState<string | null>(null);
|
|
||||||
const [createRepoSuccess, setCreateRepoSuccess] = useState<boolean>(false);
|
|
||||||
|
|
||||||
// Assume org is the authenticated user for now (could add org input later)
|
|
||||||
const githubOrg = ""; // Use empty string for now (GitHub API will default to the authenticated user)
|
|
||||||
|
|
||||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
const handleConnectToGithub = async () => {
|
|
||||||
setIsConnectingToGithub(true);
|
|
||||||
setGithubError(null);
|
|
||||||
setGithubUserCode(null);
|
|
||||||
setGithubVerificationUri(null);
|
|
||||||
setGithubStatusMessage("Requesting device code from GitHub...");
|
|
||||||
|
|
||||||
// Send IPC message to main process to start the flow
|
|
||||||
IpcClient.getInstance().startGithubDeviceFlow(appId);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const cleanupFunctions: (() => void)[] = [];
|
|
||||||
|
|
||||||
// Listener for updates (user code, verification uri, status messages)
|
|
||||||
const removeUpdateListener =
|
|
||||||
IpcClient.getInstance().onGithubDeviceFlowUpdate((data) => {
|
|
||||||
console.log("Received github:flow-update", data);
|
|
||||||
if (data.userCode) {
|
|
||||||
setGithubUserCode(data.userCode);
|
|
||||||
}
|
|
||||||
if (data.verificationUri) {
|
|
||||||
setGithubVerificationUri(data.verificationUri);
|
|
||||||
}
|
|
||||||
if (data.message) {
|
|
||||||
setGithubStatusMessage(data.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
setGithubError(null); // Clear previous errors on new update
|
|
||||||
if (!data.userCode && !data.verificationUri && data.message) {
|
|
||||||
// Likely just a status message, keep connecting state
|
|
||||||
setIsConnectingToGithub(true);
|
|
||||||
}
|
|
||||||
if (data.userCode && data.verificationUri) {
|
|
||||||
setIsConnectingToGithub(true); // Still connecting until success/error
|
|
||||||
}
|
|
||||||
});
|
|
||||||
cleanupFunctions.push(removeUpdateListener);
|
|
||||||
|
|
||||||
// Listener for success
|
|
||||||
const removeSuccessListener =
|
|
||||||
IpcClient.getInstance().onGithubDeviceFlowSuccess((data) => {
|
|
||||||
console.log("Received github:flow-success", data);
|
|
||||||
setGithubStatusMessage("Successfully connected to GitHub!");
|
|
||||||
setGithubUserCode(null); // Clear user-facing info
|
|
||||||
setGithubVerificationUri(null);
|
|
||||||
setGithubError(null);
|
|
||||||
setIsConnectingToGithub(false);
|
|
||||||
refreshSettings();
|
|
||||||
setIsExpanded(true);
|
|
||||||
});
|
|
||||||
cleanupFunctions.push(removeSuccessListener);
|
|
||||||
|
|
||||||
// Listener for errors
|
|
||||||
const removeErrorListener = IpcClient.getInstance().onGithubDeviceFlowError(
|
|
||||||
(data) => {
|
|
||||||
console.log("Received github:flow-error", data);
|
|
||||||
setGithubError(data.error || "An unknown error occurred.");
|
|
||||||
setGithubStatusMessage(null);
|
|
||||||
setGithubUserCode(null);
|
|
||||||
setGithubVerificationUri(null);
|
|
||||||
setIsConnectingToGithub(false);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
cleanupFunctions.push(removeErrorListener);
|
|
||||||
|
|
||||||
// Cleanup function to remove all listeners when component unmounts or appId changes
|
|
||||||
return () => {
|
|
||||||
cleanupFunctions.forEach((cleanup) => cleanup());
|
|
||||||
// Reset state when appId changes or component unmounts
|
|
||||||
setGithubUserCode(null);
|
|
||||||
setGithubVerificationUri(null);
|
|
||||||
setGithubError(null);
|
|
||||||
setIsConnectingToGithub(false);
|
|
||||||
setGithubStatusMessage(null);
|
|
||||||
};
|
|
||||||
}, []); // Re-run effect if appId changes
|
|
||||||
|
|
||||||
// Load available repos when GitHub is connected
|
|
||||||
useEffect(() => {
|
|
||||||
if (settings?.githubAccessToken && repoSetupMode === "existing") {
|
|
||||||
loadAvailableRepos();
|
|
||||||
}
|
|
||||||
}, [settings?.githubAccessToken, repoSetupMode]);
|
|
||||||
|
|
||||||
const loadAvailableRepos = async () => {
|
|
||||||
setIsLoadingRepos(true);
|
|
||||||
try {
|
|
||||||
const repos = await IpcClient.getInstance().listGithubRepos();
|
|
||||||
setAvailableRepos(repos);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load GitHub repos:", error);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingRepos(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load branches when a repo is selected
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedRepo && repoSetupMode === "existing") {
|
|
||||||
loadRepoBranches();
|
|
||||||
}
|
|
||||||
}, [selectedRepo, repoSetupMode]);
|
|
||||||
|
|
||||||
const loadRepoBranches = async () => {
|
|
||||||
if (!selectedRepo) return;
|
|
||||||
|
|
||||||
setIsLoadingBranches(true);
|
|
||||||
setBranchInputMode("select"); // Reset to select mode when loading new repo
|
|
||||||
setCustomBranchName(""); // Clear custom branch name
|
|
||||||
try {
|
|
||||||
const [owner, repo] = selectedRepo.split("/");
|
|
||||||
const branches = await IpcClient.getInstance().getGithubRepoBranches(
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
);
|
|
||||||
setAvailableBranches(branches);
|
|
||||||
// Default to main if available, otherwise first branch
|
|
||||||
const defaultBranch =
|
|
||||||
branches.find((b) => b.name === "main" || b.name === "master") ||
|
|
||||||
branches[0];
|
|
||||||
if (defaultBranch) {
|
|
||||||
setSelectedBranch(defaultBranch.name);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load repo branches:", error);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingBranches(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkRepoAvailability = useCallback(
|
|
||||||
async (name: string) => {
|
|
||||||
setRepoCheckError(null);
|
|
||||||
setRepoAvailable(null);
|
|
||||||
if (!name) return;
|
|
||||||
setIsCheckingRepo(true);
|
|
||||||
try {
|
|
||||||
const result = await IpcClient.getInstance().checkGithubRepoAvailable(
|
|
||||||
githubOrg,
|
|
||||||
name,
|
|
||||||
);
|
|
||||||
setRepoAvailable(result.available);
|
|
||||||
if (!result.available) {
|
|
||||||
setRepoCheckError(
|
|
||||||
result.error || "Repository name is not available.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setRepoCheckError(err.message || "Failed to check repo availability.");
|
|
||||||
} finally {
|
|
||||||
setIsCheckingRepo(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[githubOrg],
|
|
||||||
);
|
|
||||||
|
|
||||||
const debouncedCheckRepoAvailability = useCallback(
|
|
||||||
(name: string) => {
|
|
||||||
if (debounceTimeoutRef.current) {
|
|
||||||
clearTimeout(debounceTimeoutRef.current);
|
|
||||||
}
|
|
||||||
debounceTimeoutRef.current = setTimeout(() => {
|
|
||||||
checkRepoAvailability(name);
|
|
||||||
}, 500);
|
|
||||||
},
|
|
||||||
[checkRepoAvailability],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSetupRepo = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!appId) return;
|
|
||||||
|
|
||||||
setCreateRepoError(null);
|
|
||||||
setIsCreatingRepo(true);
|
|
||||||
setCreateRepoSuccess(false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (repoSetupMode === "create") {
|
|
||||||
await IpcClient.getInstance().createGithubRepo(
|
|
||||||
githubOrg,
|
|
||||||
repoName,
|
|
||||||
appId,
|
|
||||||
selectedBranch,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const [owner, repo] = selectedRepo.split("/");
|
|
||||||
const branchToUse =
|
|
||||||
branchInputMode === "custom" ? customBranchName : selectedBranch;
|
|
||||||
await IpcClient.getInstance().connectToExistingGithubRepo(
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
branchToUse,
|
|
||||||
appId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setCreateRepoSuccess(true);
|
|
||||||
setRepoCheckError(null);
|
|
||||||
handleRepoSetupComplete();
|
|
||||||
} catch (err: any) {
|
|
||||||
setCreateRepoError(
|
|
||||||
err.message ||
|
|
||||||
`Failed to ${repoSetupMode === "create" ? "create" : "connect to"} repository.`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsCreatingRepo(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!settings?.githubAccessToken) {
|
|
||||||
return (
|
|
||||||
<div className="mt-1 w-full" data-testid="github-unconnected-repo">
|
|
||||||
<Button
|
|
||||||
onClick={handleConnectToGithub}
|
|
||||||
className="cursor-pointer w-full py-5 flex justify-center items-center gap-2"
|
|
||||||
size="lg"
|
|
||||||
variant="outline"
|
|
||||||
disabled={isConnectingToGithub} // Also disable if appId is null
|
|
||||||
>
|
|
||||||
Connect to GitHub
|
|
||||||
<Github className="h-5 w-5" />
|
|
||||||
{isConnectingToGithub && (
|
|
||||||
<svg
|
|
||||||
className="animate-spin h-5 w-5 ml-2"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
{/* GitHub Connection Status/Instructions */}
|
|
||||||
{(githubUserCode || githubStatusMessage || githubError) && (
|
|
||||||
<div className="mt-6 p-4 border rounded-md bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600">
|
|
||||||
<h4 className="font-medium mb-2">GitHub Connection</h4>
|
|
||||||
{githubError && (
|
|
||||||
<p className="text-red-600 dark:text-red-400 mb-2">
|
|
||||||
Error: {githubError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{githubUserCode && githubVerificationUri && (
|
|
||||||
<div className="mb-2">
|
|
||||||
<p>
|
|
||||||
1. Go to:
|
|
||||||
<a
|
|
||||||
href={githubVerificationUri} // Make it a direct link
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
githubVerificationUri,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="ml-1 text-blue-600 hover:underline dark:text-blue-400"
|
|
||||||
>
|
|
||||||
{githubVerificationUri}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
2. Enter code:
|
|
||||||
<strong className="ml-1 font-mono text-lg tracking-wider bg-gray-200 dark:bg-gray-600 px-2 py-0.5 rounded">
|
|
||||||
{githubUserCode}
|
|
||||||
</strong>
|
|
||||||
<button
|
|
||||||
className="ml-2 p-1 rounded-md hover:bg-gray-300 dark:hover:bg-gray-500 focus:outline-none"
|
|
||||||
onClick={() => {
|
|
||||||
if (githubUserCode) {
|
|
||||||
navigator.clipboard
|
|
||||||
.writeText(githubUserCode)
|
|
||||||
.then(() => {
|
|
||||||
setCodeCopied(true);
|
|
||||||
setTimeout(() => setCodeCopied(false), 2000);
|
|
||||||
})
|
|
||||||
.catch((err) =>
|
|
||||||
console.error("Failed to copy code:", err),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title="Copy to clipboard"
|
|
||||||
>
|
|
||||||
{codeCopied ? (
|
|
||||||
<Check className="h-4 w-4 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<Clipboard className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{githubStatusMessage && (
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{githubStatusMessage}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full" data-testid="github-setup-repo">
|
|
||||||
{/* Collapsible Header */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={!isExpanded ? () => setIsExpanded(true) : undefined}
|
|
||||||
className={`w-full p-4 text-left transition-colors rounded-md flex items-center justify-between ${
|
|
||||||
!isExpanded
|
|
||||||
? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="font-medium">Set up your GitHub repo</span>
|
|
||||||
{isExpanded ? undefined : (
|
|
||||||
<ChevronRight className="h-4 w-4 text-gray-500" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Collapsible Content */}
|
|
||||||
<div
|
|
||||||
className={`overflow-hidden transition-all duration-300 ease-in-out ${
|
|
||||||
isExpanded ? "max-h-[800px] opacity-100" : "max-h-0 opacity-0"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="p-4 pt-0 space-y-4">
|
|
||||||
{/* Mode Selection */}
|
|
||||||
<div>
|
|
||||||
<div className="flex rounded-md border border-gray-200 dark:border-gray-700">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={repoSetupMode === "create" ? "default" : "ghost"}
|
|
||||||
className={`flex-1 rounded-none rounded-l-md border-0 ${
|
|
||||||
repoSetupMode === "create"
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
setRepoSetupMode("create");
|
|
||||||
setCreateRepoError(null);
|
|
||||||
setCreateRepoSuccess(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Create new repo
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={repoSetupMode === "existing" ? "default" : "ghost"}
|
|
||||||
className={`flex-1 rounded-none rounded-r-md border-0 border-l border-gray-200 dark:border-gray-700 ${
|
|
||||||
repoSetupMode === "existing"
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
setRepoSetupMode("existing");
|
|
||||||
setCreateRepoError(null);
|
|
||||||
setCreateRepoSuccess(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Connect to existing repo
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form className="space-y-4" onSubmit={handleSetupRepo}>
|
|
||||||
{repoSetupMode === "create" ? (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<Label className="block text-sm font-medium">
|
|
||||||
Repository Name
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
data-testid="github-create-repo-name-input"
|
|
||||||
className="w-full mt-1"
|
|
||||||
value={repoName}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newValue = e.target.value;
|
|
||||||
setRepoName(newValue);
|
|
||||||
setRepoAvailable(null);
|
|
||||||
setRepoCheckError(null);
|
|
||||||
debouncedCheckRepoAvailability(newValue);
|
|
||||||
}}
|
|
||||||
disabled={isCreatingRepo}
|
|
||||||
/>
|
|
||||||
{isCheckingRepo && (
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Checking availability...
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{repoAvailable === true && (
|
|
||||||
<p className="text-xs text-green-600 mt-1">
|
|
||||||
Repository name is available!
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{repoAvailable === false && (
|
|
||||||
<p className="text-xs text-red-600 mt-1">
|
|
||||||
{repoCheckError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<Label className="block text-sm font-medium">
|
|
||||||
Select Repository
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={selectedRepo}
|
|
||||||
onValueChange={setSelectedRepo}
|
|
||||||
disabled={isLoadingRepos}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
className="w-full mt-1"
|
|
||||||
data-testid="github-repo-select"
|
|
||||||
>
|
|
||||||
<SelectValue
|
|
||||||
placeholder={
|
|
||||||
isLoadingRepos
|
|
||||||
? "Loading repositories..."
|
|
||||||
: "Select a repository"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{availableRepos.map((repo) => (
|
|
||||||
<SelectItem key={repo.full_name} value={repo.full_name}>
|
|
||||||
{repo.full_name} {repo.private && "(private)"}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Branch Selection */}
|
|
||||||
<div>
|
|
||||||
<Label className="block text-sm font-medium">Branch</Label>
|
|
||||||
{repoSetupMode === "existing" && selectedRepo ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Select
|
|
||||||
value={
|
|
||||||
branchInputMode === "select" ? selectedBranch : "custom"
|
|
||||||
}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (value === "custom") {
|
|
||||||
setBranchInputMode("custom");
|
|
||||||
setCustomBranchName("");
|
|
||||||
} else {
|
|
||||||
setBranchInputMode("select");
|
|
||||||
setSelectedBranch(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isLoadingBranches}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
className="w-full mt-1"
|
|
||||||
data-testid="github-branch-select"
|
|
||||||
>
|
|
||||||
<SelectValue
|
|
||||||
placeholder={
|
|
||||||
isLoadingBranches
|
|
||||||
? "Loading branches..."
|
|
||||||
: "Select a branch"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{availableBranches.map((branch) => (
|
|
||||||
<SelectItem key={branch.name} value={branch.name}>
|
|
||||||
{branch.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
<SelectItem value="custom">
|
|
||||||
<span className="font-medium">
|
|
||||||
✏️ Type custom branch name
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{branchInputMode === "custom" && (
|
|
||||||
<Input
|
|
||||||
data-testid="github-custom-branch-input"
|
|
||||||
className="w-full"
|
|
||||||
value={customBranchName}
|
|
||||||
onChange={(e) => setCustomBranchName(e.target.value)}
|
|
||||||
placeholder="Enter branch name (e.g., feature/new-feature)"
|
|
||||||
disabled={isCreatingRepo}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
className="w-full mt-1"
|
|
||||||
value={selectedBranch}
|
|
||||||
onChange={(e) => setSelectedBranch(e.target.value)}
|
|
||||||
placeholder="main"
|
|
||||||
disabled={isCreatingRepo}
|
|
||||||
data-testid="github-new-repo-branch-input"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={
|
|
||||||
isCreatingRepo ||
|
|
||||||
(repoSetupMode === "create" &&
|
|
||||||
(repoAvailable === false || !repoName)) ||
|
|
||||||
(repoSetupMode === "existing" &&
|
|
||||||
(!selectedRepo ||
|
|
||||||
!selectedBranch ||
|
|
||||||
(branchInputMode === "custom" && !customBranchName.trim())))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isCreatingRepo
|
|
||||||
? repoSetupMode === "create"
|
|
||||||
? "Creating..."
|
|
||||||
: "Connecting..."
|
|
||||||
: repoSetupMode === "create"
|
|
||||||
? "Create Repo"
|
|
||||||
: "Connect to Repo"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{createRepoError && (
|
|
||||||
<p className="text-red-600 mt-2">{createRepoError}</p>
|
|
||||||
)}
|
|
||||||
{createRepoSuccess && (
|
|
||||||
<p className="text-green-600 mt-2">
|
|
||||||
{repoSetupMode === "create"
|
|
||||||
? "Repository created and linked!"
|
|
||||||
: "Connected to repository!"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GitHubConnector({
|
|
||||||
appId,
|
|
||||||
folderName,
|
|
||||||
expanded,
|
|
||||||
}: GitHubConnectorProps) {
|
|
||||||
const { app, refreshApp } = useLoadApp(appId);
|
|
||||||
const { settings, refreshSettings } = useSettings();
|
|
||||||
const [pendingAutoSync, setPendingAutoSync] = useState(false);
|
|
||||||
|
|
||||||
const handleRepoSetupComplete = useCallback(() => {
|
|
||||||
setPendingAutoSync(true);
|
|
||||||
refreshApp();
|
|
||||||
}, [refreshApp]);
|
|
||||||
|
|
||||||
const handleAutoSyncComplete = useCallback(() => {
|
|
||||||
setPendingAutoSync(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (app?.githubOrg && app?.githubRepo && appId) {
|
|
||||||
return (
|
|
||||||
<ConnectedGitHubConnector
|
|
||||||
appId={appId}
|
|
||||||
app={app}
|
|
||||||
refreshApp={refreshApp}
|
|
||||||
triggerAutoSync={pendingAutoSync}
|
|
||||||
onAutoSyncComplete={handleAutoSyncComplete}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<UnconnectedGitHubConnector
|
|
||||||
appId={appId}
|
|
||||||
folderName={folderName}
|
|
||||||
settings={settings}
|
|
||||||
refreshSettings={refreshSettings}
|
|
||||||
handleRepoSetupComplete={handleRepoSetupComplete}
|
|
||||||
expanded={expanded}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Github } from "lucide-react";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import { showSuccess, showError } from "@/lib/toast";
|
|
||||||
|
|
||||||
export function GitHubIntegration() {
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
|
||||||
|
|
||||||
const handleDisconnectFromGithub = async () => {
|
|
||||||
setIsDisconnecting(true);
|
|
||||||
try {
|
|
||||||
const result = await updateSettings({
|
|
||||||
githubAccessToken: undefined,
|
|
||||||
});
|
|
||||||
if (result) {
|
|
||||||
showSuccess("Successfully disconnected from GitHub");
|
|
||||||
} else {
|
|
||||||
showError("Failed to disconnect from GitHub");
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
showError(
|
|
||||||
err.message || "An error occurred while disconnecting from GitHub",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsDisconnecting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isConnected = !!settings?.githubAccessToken;
|
|
||||||
|
|
||||||
if (!isConnected) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
GitHub Integration
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
Your account is connected to GitHub.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleDisconnectFromGithub}
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
disabled={isDisconnecting}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{isDisconnecting ? "Disconnecting..." : "Disconnect from GitHub"}
|
|
||||||
<Github className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
import { LoadingBlock, VanillaMarkdownParser } from "@/components/LoadingBlock";
|
|
||||||
|
|
||||||
interface HelpBotDialogProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Message {
|
|
||||||
role: "user" | "assistant";
|
|
||||||
content: string;
|
|
||||||
reasoning?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HelpBotDialog({ isOpen, onClose }: HelpBotDialogProps) {
|
|
||||||
const [input, setInput] = useState("");
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
|
||||||
const [streaming, setStreaming] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const assistantBufferRef = useRef("");
|
|
||||||
const reasoningBufferRef = useRef("");
|
|
||||||
const flushTimerRef = useRef<number | null>(null);
|
|
||||||
const FLUSH_INTERVAL_MS = 100;
|
|
||||||
|
|
||||||
const sessionId = useMemo(() => uuidv4(), [isOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
// Clean up when dialog closes
|
|
||||||
setMessages([]);
|
|
||||||
setInput("");
|
|
||||||
setError(null);
|
|
||||||
assistantBufferRef.current = "";
|
|
||||||
reasoningBufferRef.current = "";
|
|
||||||
|
|
||||||
// Clear the flush timer
|
|
||||||
if (flushTimerRef.current) {
|
|
||||||
window.clearInterval(flushTimerRef.current);
|
|
||||||
flushTimerRef.current = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
// Cleanup on component unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
// Clear the flush timer on unmount
|
|
||||||
if (flushTimerRef.current) {
|
|
||||||
window.clearInterval(flushTimerRef.current);
|
|
||||||
flushTimerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSend = async () => {
|
|
||||||
const trimmed = input.trim();
|
|
||||||
if (!trimmed || streaming) return;
|
|
||||||
setError(null); // Clear any previous errors
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ role: "user", content: trimmed },
|
|
||||||
{ role: "assistant", content: "", reasoning: "" },
|
|
||||||
]);
|
|
||||||
assistantBufferRef.current = "";
|
|
||||||
reasoningBufferRef.current = "";
|
|
||||||
setInput("");
|
|
||||||
setStreaming(true);
|
|
||||||
|
|
||||||
IpcClient.getInstance().startHelpChat(sessionId, trimmed, {
|
|
||||||
onChunk: (delta) => {
|
|
||||||
// Buffer assistant content; UI will flush on interval for smoothness
|
|
||||||
assistantBufferRef.current += delta;
|
|
||||||
},
|
|
||||||
onEnd: () => {
|
|
||||||
// Final flush then stop streaming
|
|
||||||
setMessages((prev) => {
|
|
||||||
const next = [...prev];
|
|
||||||
const lastIdx = next.length - 1;
|
|
||||||
if (lastIdx >= 0 && next[lastIdx].role === "assistant") {
|
|
||||||
next[lastIdx] = {
|
|
||||||
...next[lastIdx],
|
|
||||||
content: assistantBufferRef.current,
|
|
||||||
reasoning: reasoningBufferRef.current,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setStreaming(false);
|
|
||||||
if (flushTimerRef.current) {
|
|
||||||
window.clearInterval(flushTimerRef.current);
|
|
||||||
flushTimerRef.current = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (errorMessage: string) => {
|
|
||||||
setError(errorMessage);
|
|
||||||
setStreaming(false);
|
|
||||||
|
|
||||||
// Clear the flush timer
|
|
||||||
if (flushTimerRef.current) {
|
|
||||||
window.clearInterval(flushTimerRef.current);
|
|
||||||
flushTimerRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear the buffers
|
|
||||||
assistantBufferRef.current = "";
|
|
||||||
reasoningBufferRef.current = "";
|
|
||||||
|
|
||||||
// Remove the empty assistant message that was added optimistically
|
|
||||||
setMessages((prev) => {
|
|
||||||
const next = [...prev];
|
|
||||||
if (
|
|
||||||
next.length > 0 &&
|
|
||||||
next[next.length - 1].role === "assistant" &&
|
|
||||||
!next[next.length - 1].content
|
|
||||||
) {
|
|
||||||
next.pop();
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start smooth flush interval
|
|
||||||
if (flushTimerRef.current) {
|
|
||||||
window.clearInterval(flushTimerRef.current);
|
|
||||||
}
|
|
||||||
flushTimerRef.current = window.setInterval(() => {
|
|
||||||
setMessages((prev) => {
|
|
||||||
const next = [...prev];
|
|
||||||
const lastIdx = next.length - 1;
|
|
||||||
if (lastIdx >= 0 && next[lastIdx].role === "assistant") {
|
|
||||||
const current = next[lastIdx];
|
|
||||||
// Only update if there's any new data to apply
|
|
||||||
if (
|
|
||||||
current.content !== assistantBufferRef.current ||
|
|
||||||
current.reasoning !== reasoningBufferRef.current
|
|
||||||
) {
|
|
||||||
next[lastIdx] = {
|
|
||||||
...current,
|
|
||||||
content: assistantBufferRef.current,
|
|
||||||
reasoning: reasoningBufferRef.current,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, FLUSH_INTERVAL_MS);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
||||||
<DialogContent className="max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Dyad Help Bot</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex flex-col gap-3 h-[480px]">
|
|
||||||
{error && (
|
|
||||||
<div className="bg-destructive/10 border border-destructive/20 rounded-md p-3">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<div className="text-destructive text-sm font-medium">
|
|
||||||
Error:
|
|
||||||
</div>
|
|
||||||
<div className="text-destructive text-sm flex-1">{error}</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setError(null)}
|
|
||||||
className="text-destructive hover:text-destructive/80 text-xs"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 overflow-auto rounded-md border p-3 bg-(--background-lightest)">
|
|
||||||
{messages.length === 0 ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Ask a question about using Dyad.
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground/70 bg-muted/50 rounded-md p-3">
|
|
||||||
This conversation may be logged and used to improve the
|
|
||||||
product. Please do not put any sensitive information in here.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{messages.map((m, i) => (
|
|
||||||
<div key={i}>
|
|
||||||
{m.role === "user" ? (
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="inline-block rounded-lg px-3 py-2 bg-primary text-primary-foreground">
|
|
||||||
{m.content}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-left">
|
|
||||||
{streaming && i === messages.length - 1 && (
|
|
||||||
<LoadingBlock
|
|
||||||
isStreaming={streaming && i === messages.length - 1}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{m.content && (
|
|
||||||
<div className="inline-block rounded-lg px-3 py-2 bg-muted prose dark:prose-invert prose-headings:mb-2 prose-p:my-1 prose-pre:my-0 max-w-none">
|
|
||||||
<VanillaMarkdownParser content={m.content} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
className="flex-1 h-10 rounded-md border bg-background px-3 text-sm"
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
placeholder="Type your question..."
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSend();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button onClick={handleSend} disabled={streaming || !input.trim()}>
|
|
||||||
{streaming ? "Sending..." : "Send"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,482 +0,0 @@
|
|||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
BookOpenIcon,
|
|
||||||
BugIcon,
|
|
||||||
UploadIcon,
|
|
||||||
ChevronLeftIcon,
|
|
||||||
CheckIcon,
|
|
||||||
XIcon,
|
|
||||||
FileIcon,
|
|
||||||
SparklesIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
|
||||||
import { ChatLogsData } from "@/ipc/ipc_types";
|
|
||||||
import { showError } from "@/lib/toast";
|
|
||||||
import { HelpBotDialog } from "./HelpBotDialog";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import { BugScreenshotDialog } from "./BugScreenshotDialog";
|
|
||||||
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
|
|
||||||
|
|
||||||
interface HelpDialogProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HelpDialog({ isOpen, onClose }: HelpDialogProps) {
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
|
||||||
const [reviewMode, setReviewMode] = useState(false);
|
|
||||||
const [chatLogsData, setChatLogsData] = useState<ChatLogsData | null>(null);
|
|
||||||
const [uploadComplete, setUploadComplete] = useState(false);
|
|
||||||
const [sessionId, setSessionId] = useState("");
|
|
||||||
const [isHelpBotOpen, setIsHelpBotOpen] = useState(false);
|
|
||||||
const [isBugScreenshotOpen, setIsBugScreenshotOpen] = useState(false);
|
|
||||||
const selectedChatId = useAtomValue(selectedChatIdAtom);
|
|
||||||
const { settings } = useSettings();
|
|
||||||
const { userBudget } = useUserBudgetInfo();
|
|
||||||
const isDyadProUser = settings?.providerSettings?.["auto"]?.apiKey?.value;
|
|
||||||
|
|
||||||
// Function to reset all dialog state
|
|
||||||
const resetDialogState = () => {
|
|
||||||
setIsLoading(false);
|
|
||||||
setIsUploading(false);
|
|
||||||
setReviewMode(false);
|
|
||||||
setChatLogsData(null);
|
|
||||||
setUploadComplete(false);
|
|
||||||
setSessionId("");
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reset state when dialog closes or reopens
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
resetDialogState();
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
// Wrap the original onClose to also reset state
|
|
||||||
const handleClose = () => {
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReportBug = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
// Get system debug info
|
|
||||||
const debugInfo = await IpcClient.getInstance().getSystemDebugInfo();
|
|
||||||
|
|
||||||
// Create a formatted issue body with the debug info
|
|
||||||
const issueBody = `
|
|
||||||
<!--
|
|
||||||
⚠️ IMPORTANT: All sections marked as required must be completed in English.
|
|
||||||
Issues that do not meet these requirements will be closed and may need to be resubmitted.
|
|
||||||
-->
|
|
||||||
|
|
||||||
## Bug Description (required)
|
|
||||||
<!-- Please describe the issue you're experiencing -->
|
|
||||||
|
|
||||||
## Steps to Reproduce (required)
|
|
||||||
<!-- Please list the steps to reproduce the issue -->
|
|
||||||
|
|
||||||
## Expected Behavior (required)
|
|
||||||
<!-- What did you expect to happen? -->
|
|
||||||
|
|
||||||
## Actual Behavior (required)
|
|
||||||
<!-- What actually happened? -->
|
|
||||||
|
|
||||||
## Screenshot (Optional)
|
|
||||||
<!-- Screenshot of the bug -->
|
|
||||||
|
|
||||||
## System Information
|
|
||||||
- Dyad Version: ${debugInfo.dyadVersion}
|
|
||||||
- Platform: ${debugInfo.platform}
|
|
||||||
- Architecture: ${debugInfo.architecture}
|
|
||||||
- Node Version: ${debugInfo.nodeVersion || "n/a"}
|
|
||||||
- PNPM Version: ${debugInfo.pnpmVersion || "n/a"}
|
|
||||||
- Node Path: ${debugInfo.nodePath || "n/a"}
|
|
||||||
- Pro User ID: ${userBudget?.redactedUserId || "n/a"}
|
|
||||||
- Telemetry ID: ${debugInfo.telemetryId || "n/a"}
|
|
||||||
- Model: ${debugInfo.selectedLanguageModel || "n/a"}
|
|
||||||
|
|
||||||
## Logs
|
|
||||||
\`\`\`
|
|
||||||
${debugInfo.logs.slice(-3_500) || "No logs available"}
|
|
||||||
\`\`\`
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Create the GitHub issue URL with the pre-filled body
|
|
||||||
const encodedBody = encodeURIComponent(issueBody);
|
|
||||||
const encodedTitle = encodeURIComponent("[bug] <WRITE TITLE HERE>");
|
|
||||||
const labels = ["bug"];
|
|
||||||
if (isDyadProUser) {
|
|
||||||
labels.push("pro");
|
|
||||||
}
|
|
||||||
const githubIssueUrl = `https://github.com/dyad-sh/dyad/issues/new?title=${encodedTitle}&labels=${labels}&body=${encodedBody}`;
|
|
||||||
|
|
||||||
// Open the pre-filled GitHub issue page
|
|
||||||
IpcClient.getInstance().openExternalUrl(githubIssueUrl);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to prepare bug report:", error);
|
|
||||||
// Fallback to opening the regular GitHub issue page
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
"https://github.com/dyad-sh/dyad/issues/new",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUploadChatSession = async () => {
|
|
||||||
if (!selectedChatId) {
|
|
||||||
alert("Please select a chat first");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsUploading(true);
|
|
||||||
try {
|
|
||||||
// Get chat logs (includes debug info, chat data, and codebase)
|
|
||||||
const chatLogs =
|
|
||||||
await IpcClient.getInstance().getChatLogs(selectedChatId);
|
|
||||||
|
|
||||||
// Store data for review and switch to review mode
|
|
||||||
setChatLogsData(chatLogs);
|
|
||||||
setReviewMode(true);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to upload chat session:", error);
|
|
||||||
alert(
|
|
||||||
"Failed to upload chat session. Please try again or report manually.",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsUploading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmitChatLogs = async () => {
|
|
||||||
if (!chatLogsData) return;
|
|
||||||
|
|
||||||
setIsUploading(true);
|
|
||||||
try {
|
|
||||||
// Prepare data for upload
|
|
||||||
const chatLogsJson = {
|
|
||||||
systemInfo: chatLogsData.debugInfo,
|
|
||||||
chat: chatLogsData.chat,
|
|
||||||
codebaseSnippet: chatLogsData.codebase,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get signed URL
|
|
||||||
const response = await fetch(
|
|
||||||
"https://upload-logs.dyad.sh/generate-upload-url",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
extension: "json",
|
|
||||||
contentType: "application/json",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
showError(`Failed to get upload URL: ${response.statusText}`);
|
|
||||||
throw new Error(`Failed to get upload URL: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { uploadUrl, filename } = await response.json();
|
|
||||||
|
|
||||||
await IpcClient.getInstance().uploadToSignedUrl(
|
|
||||||
uploadUrl,
|
|
||||||
"application/json",
|
|
||||||
chatLogsJson,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Extract session ID (filename without extension)
|
|
||||||
const sessionId = filename.replace(".json", "");
|
|
||||||
setSessionId(sessionId);
|
|
||||||
setUploadComplete(true);
|
|
||||||
setReviewMode(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to upload chat logs:", error);
|
|
||||||
alert("Failed to upload chat logs. Please try again.");
|
|
||||||
} finally {
|
|
||||||
setIsUploading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelReview = () => {
|
|
||||||
setReviewMode(false);
|
|
||||||
setChatLogsData(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenGitHubIssue = () => {
|
|
||||||
// Create a GitHub issue with the session ID
|
|
||||||
const issueBody = `
|
|
||||||
<!--
|
|
||||||
⚠️ IMPORTANT: All sections marked as required must be completed in English.
|
|
||||||
Issues that do not meet these requirements will be closed and may need to be resubmitted.
|
|
||||||
-->
|
|
||||||
|
|
||||||
Session ID: ${sessionId}
|
|
||||||
Pro User ID: ${userBudget?.redactedUserId || "n/a"}
|
|
||||||
|
|
||||||
## Issue Description (required)
|
|
||||||
<!-- Please describe the issue you're experiencing -->
|
|
||||||
|
|
||||||
## Expected Behavior (required)
|
|
||||||
<!-- What did you expect to happen? -->
|
|
||||||
|
|
||||||
## Actual Behavior (required)
|
|
||||||
<!-- What actually happened? -->
|
|
||||||
`;
|
|
||||||
|
|
||||||
const encodedBody = encodeURIComponent(issueBody);
|
|
||||||
const encodedTitle = encodeURIComponent("[session report] <add title>");
|
|
||||||
const labels = ["support"];
|
|
||||||
if (isDyadProUser) {
|
|
||||||
labels.push("pro");
|
|
||||||
}
|
|
||||||
const githubIssueUrl = `https://github.com/dyad-sh/dyad/issues/new?title=${encodedTitle}&labels=${labels}&body=${encodedBody}`;
|
|
||||||
|
|
||||||
IpcClient.getInstance().openExternalUrl(githubIssueUrl);
|
|
||||||
handleClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (uploadComplete) {
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Upload Complete</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="py-6 flex flex-col items-center space-y-4">
|
|
||||||
<div className="bg-green-50 dark:bg-green-900/20 p-6 rounded-full">
|
|
||||||
<CheckIcon className="h-8 w-8 text-green-600 dark:text-green-400" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-medium">
|
|
||||||
Chat Logs Uploaded Successfully
|
|
||||||
</h3>
|
|
||||||
<div className="bg-slate-100 dark:bg-slate-800 p-3 rounded flex items-center space-x-2 font-mono text-sm">
|
|
||||||
<FileIcon
|
|
||||||
className="h-4 w-4 cursor-pointer"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(sessionId);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to copy session ID:", err);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>{sessionId}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-center text-sm">
|
|
||||||
You must open a GitHub issue for us to investigate. Without a
|
|
||||||
linked issue, your report will not be reviewed.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button onClick={handleOpenGitHubIssue} className="w-full">
|
|
||||||
Open GitHub Issue
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reviewMode && chatLogsData) {
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
|
||||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="mr-2 p-0 h-8 w-8"
|
|
||||||
onClick={handleCancelReview}
|
|
||||||
>
|
|
||||||
<ChevronLeftIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
OK to upload chat session?
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogDescription>
|
|
||||||
Please review the information that will be submitted. Your chat
|
|
||||||
messages, system information, and a snapshot of your codebase will
|
|
||||||
be included.
|
|
||||||
</DialogDescription>
|
|
||||||
|
|
||||||
<div className="space-y-4 overflow-y-auto flex-grow">
|
|
||||||
<div className="border rounded-md p-3">
|
|
||||||
<h3 className="font-medium mb-2">Chat Messages</h3>
|
|
||||||
<div className="text-sm bg-slate-50 dark:bg-slate-900 rounded p-2 max-h-40 overflow-y-auto">
|
|
||||||
{chatLogsData.chat.messages.map((msg) => (
|
|
||||||
<div key={msg.id} className="mb-2">
|
|
||||||
<span className="font-semibold">
|
|
||||||
{msg.role === "user" ? "You" : "Assistant"}:{" "}
|
|
||||||
</span>
|
|
||||||
<span>{msg.content}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border rounded-md p-3">
|
|
||||||
<h3 className="font-medium mb-2">Codebase Snapshot</h3>
|
|
||||||
<div className="text-sm bg-slate-50 dark:bg-slate-900 rounded p-2 max-h-40 overflow-y-auto font-mono">
|
|
||||||
{chatLogsData.codebase}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border rounded-md p-3">
|
|
||||||
<h3 className="font-medium mb-2">Logs</h3>
|
|
||||||
<div className="text-sm bg-slate-50 dark:bg-slate-900 rounded p-2 max-h-40 overflow-y-auto font-mono">
|
|
||||||
{chatLogsData.debugInfo.logs}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border rounded-md p-3">
|
|
||||||
<h3 className="font-medium mb-2">System Information</h3>
|
|
||||||
<div className="text-sm bg-slate-50 dark:bg-slate-900 rounded p-2 max-h-32 overflow-y-auto">
|
|
||||||
<p>Dyad Version: {chatLogsData.debugInfo.dyadVersion}</p>
|
|
||||||
<p>Platform: {chatLogsData.debugInfo.platform}</p>
|
|
||||||
<p>Architecture: {chatLogsData.debugInfo.architecture}</p>
|
|
||||||
<p>
|
|
||||||
Node Version:{" "}
|
|
||||||
{chatLogsData.debugInfo.nodeVersion || "Not available"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between mt-4 pt-2 sticky bottom-0 bg-background">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleCancelReview}
|
|
||||||
className="flex items-center"
|
|
||||||
>
|
|
||||||
<XIcon className="mr-2 h-4 w-4" /> Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmitChatLogs}
|
|
||||||
className="flex items-center"
|
|
||||||
disabled={isUploading}
|
|
||||||
>
|
|
||||||
{isUploading ? (
|
|
||||||
"Uploading..."
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckIcon className="mr-2 h-4 w-4" /> Upload
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Need help with Dyad?</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogDescription className="">
|
|
||||||
If you need help or want to report an issue, here are some options:
|
|
||||||
</DialogDescription>
|
|
||||||
<div className="flex flex-col space-y-4 w-full">
|
|
||||||
{isDyadProUser ? (
|
|
||||||
<div className="flex flex-col space-y-2">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onClick={() => {
|
|
||||||
setIsHelpBotOpen(true);
|
|
||||||
}}
|
|
||||||
className="w-full py-6 border-primary/50 shadow-sm shadow-primary/10 transition-all hover:shadow-md hover:shadow-primary/15"
|
|
||||||
>
|
|
||||||
<SparklesIcon className="mr-2 h-5 w-5" /> Chat with Dyad help
|
|
||||||
bot (Pro)
|
|
||||||
</Button>
|
|
||||||
<p className="text-sm text-muted-foreground px-2">
|
|
||||||
Opens an in-app help chat assistant that searches through Dyad's
|
|
||||||
docs.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col space-y-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
"https://www.dyad.sh/docs",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="w-full py-6 bg-(--background-lightest)"
|
|
||||||
>
|
|
||||||
<BookOpenIcon className="mr-2 h-5 w-5" /> Open Docs
|
|
||||||
</Button>
|
|
||||||
<p className="text-sm text-muted-foreground px-2">
|
|
||||||
Get help with common questions and issues.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col space-y-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
handleClose();
|
|
||||||
setIsBugScreenshotOpen(true);
|
|
||||||
}}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full py-6 bg-(--background-lightest)"
|
|
||||||
>
|
|
||||||
<BugIcon className="mr-2 h-5 w-5" />{" "}
|
|
||||||
{isLoading ? "Preparing Report..." : "Report a Bug"}
|
|
||||||
</Button>
|
|
||||||
<p className="text-sm text-muted-foreground px-2">
|
|
||||||
We'll auto-fill your report with system info and logs. You can
|
|
||||||
review it for any sensitive info before submitting.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col space-y-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleUploadChatSession}
|
|
||||||
disabled={isUploading || !selectedChatId}
|
|
||||||
className="w-full py-6 bg-(--background-lightest)"
|
|
||||||
>
|
|
||||||
<UploadIcon className="mr-2 h-5 w-5" />{" "}
|
|
||||||
{isUploading ? "Preparing Upload..." : "Upload Chat Session"}
|
|
||||||
</Button>
|
|
||||||
<p className="text-sm text-muted-foreground px-2">
|
|
||||||
Share chat logs and code for troubleshooting. Data is used only to
|
|
||||||
resolve your issue and auto-deleted after a limited time.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
<HelpBotDialog
|
|
||||||
isOpen={isHelpBotOpen}
|
|
||||||
onClose={() => setIsHelpBotOpen(false)}
|
|
||||||
/>
|
|
||||||
<BugScreenshotDialog
|
|
||||||
isOpen={isBugScreenshotOpen}
|
|
||||||
onClose={() => setIsBugScreenshotOpen(false)}
|
|
||||||
handleReportBug={handleReportBug}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Upload } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { ImportAppDialog } from "./ImportAppDialog";
|
|
||||||
|
|
||||||
export function ImportAppButton() {
|
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="px-4 pb-1 flex justify-center">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="default"
|
|
||||||
onClick={() => setIsDialogOpen(true)}
|
|
||||||
>
|
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
|
||||||
Import App
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<ImportAppDialog
|
|
||||||
isOpen={isDialogOpen}
|
|
||||||
onClose={() => setIsDialogOpen(false)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,727 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { showError, showSuccess } from "@/lib/toast";
|
|
||||||
import { Folder, X, Loader2, Info } from "lucide-react";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { Label } from "@radix-ui/react-label";
|
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
|
||||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
|
||||||
import type { GithubRepository } from "@/ipc/ipc_types";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
|
||||||
import { useSetAtom } from "jotai";
|
|
||||||
import { useLoadApps } from "@/hooks/useLoadApps";
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from "./ui/accordion";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import { UnconnectedGitHubConnector } from "@/components/GitHubConnector";
|
|
||||||
|
|
||||||
interface ImportAppDialogProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
export const AI_RULES_PROMPT =
|
|
||||||
"Generate an AI_RULES.md file for this app. Describe the tech stack in 5-10 bullet points and describe clear rules about what libraries to use for what.";
|
|
||||||
export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
|
|
||||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
|
||||||
const [hasAiRules, setHasAiRules] = useState<boolean | null>(null);
|
|
||||||
const [customAppName, setCustomAppName] = useState<string>("");
|
|
||||||
const [nameExists, setNameExists] = useState<boolean>(false);
|
|
||||||
const [isCheckingName, setIsCheckingName] = useState<boolean>(false);
|
|
||||||
const [installCommand, setInstallCommand] = useState("");
|
|
||||||
const [startCommand, setStartCommand] = useState("");
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { streamMessage } = useStreamChat({ hasChatId: false });
|
|
||||||
const { refreshApps } = useLoadApps();
|
|
||||||
const setSelectedAppId = useSetAtom(selectedAppIdAtom);
|
|
||||||
// GitHub import state
|
|
||||||
const [repos, setRepos] = useState<GithubRepository[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [url, setUrl] = useState("");
|
|
||||||
const [importing, setImporting] = useState(false);
|
|
||||||
const { settings, refreshSettings } = useSettings();
|
|
||||||
const isAuthenticated = !!settings?.githubAccessToken;
|
|
||||||
|
|
||||||
const [githubAppName, setGithubAppName] = useState("");
|
|
||||||
const [githubNameExists, setGithubNameExists] = useState(false);
|
|
||||||
const [isCheckingGithubName, setIsCheckingGithubName] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
setGithubAppName("");
|
|
||||||
setGithubNameExists(false);
|
|
||||||
// Fetch GitHub repos if authenticated
|
|
||||||
if (isAuthenticated) {
|
|
||||||
fetchRepos();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isOpen, isAuthenticated]);
|
|
||||||
|
|
||||||
const fetchRepos = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const fetchedRepos = await IpcClient.getInstance().listGithubRepos();
|
|
||||||
setRepos(fetchedRepos);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
showError("Failed to fetch repositories.: " + (err as any).toString());
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleUrlBlur = async () => {
|
|
||||||
if (!url.trim()) return;
|
|
||||||
const repoName = extractRepoNameFromUrl(url);
|
|
||||||
if (repoName) {
|
|
||||||
setGithubAppName(repoName);
|
|
||||||
setIsCheckingGithubName(true);
|
|
||||||
try {
|
|
||||||
const result = await IpcClient.getInstance().checkAppName({
|
|
||||||
appName: repoName,
|
|
||||||
});
|
|
||||||
setGithubNameExists(result.exists);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
showError("Failed to check app name: " + (error as any).toString());
|
|
||||||
} finally {
|
|
||||||
setIsCheckingGithubName(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const extractRepoNameFromUrl = (url: string): string | null => {
|
|
||||||
const match = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
||||||
return match ? match[2] : null;
|
|
||||||
};
|
|
||||||
const handleImportFromUrl = async () => {
|
|
||||||
setImporting(true);
|
|
||||||
try {
|
|
||||||
const match = extractRepoNameFromUrl(url);
|
|
||||||
const repoName = match ? match[2] : "";
|
|
||||||
const appName = githubAppName.trim() || repoName;
|
|
||||||
const result = await IpcClient.getInstance().cloneRepoFromUrl({
|
|
||||||
url,
|
|
||||||
installCommand: installCommand.trim() || undefined,
|
|
||||||
startCommand: startCommand.trim() || undefined,
|
|
||||||
appName,
|
|
||||||
});
|
|
||||||
if ("error" in result) {
|
|
||||||
showError(result.error);
|
|
||||||
setImporting(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedAppId(result.app.id);
|
|
||||||
showSuccess(`Successfully imported ${result.app.name}`);
|
|
||||||
const chatId = await IpcClient.getInstance().createChat(result.app.id);
|
|
||||||
navigate({ to: "/chat", search: { id: chatId } });
|
|
||||||
if (!result.hasAiRules) {
|
|
||||||
streamMessage({
|
|
||||||
prompt: AI_RULES_PROMPT,
|
|
||||||
chatId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
onClose();
|
|
||||||
} catch (error: unknown) {
|
|
||||||
showError("Failed to import repository: " + (error as any).toString());
|
|
||||||
} finally {
|
|
||||||
setImporting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectRepo = async (repo: GithubRepository) => {
|
|
||||||
setImporting(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const appName = githubAppName.trim() || repo.name;
|
|
||||||
const result = await IpcClient.getInstance().cloneRepoFromUrl({
|
|
||||||
url: `https://github.com/${repo.full_name}.git`,
|
|
||||||
installCommand: installCommand.trim() || undefined,
|
|
||||||
startCommand: startCommand.trim() || undefined,
|
|
||||||
appName,
|
|
||||||
});
|
|
||||||
if ("error" in result) {
|
|
||||||
showError(result.error);
|
|
||||||
setImporting(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedAppId(result.app.id);
|
|
||||||
showSuccess(`Successfully imported ${result.app.name}`);
|
|
||||||
const chatId = await IpcClient.getInstance().createChat(result.app.id);
|
|
||||||
navigate({ to: "/chat", search: { id: chatId } });
|
|
||||||
if (!result.hasAiRules) {
|
|
||||||
streamMessage({
|
|
||||||
prompt: AI_RULES_PROMPT,
|
|
||||||
chatId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
onClose();
|
|
||||||
} catch (error: unknown) {
|
|
||||||
showError("Failed to import repository: " + (error as any).toString());
|
|
||||||
} finally {
|
|
||||||
setImporting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGithubAppNameChange = async (
|
|
||||||
e: React.ChangeEvent<HTMLInputElement>,
|
|
||||||
) => {
|
|
||||||
const newName = e.target.value;
|
|
||||||
setGithubAppName(newName);
|
|
||||||
if (newName.trim()) {
|
|
||||||
setIsCheckingGithubName(true);
|
|
||||||
try {
|
|
||||||
const result = await IpcClient.getInstance().checkAppName({
|
|
||||||
appName: newName,
|
|
||||||
});
|
|
||||||
setGithubNameExists(result.exists);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
showError("Failed to check app name: " + (error as any).toString());
|
|
||||||
} finally {
|
|
||||||
setIsCheckingGithubName(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkAppName = async (name: string): Promise<void> => {
|
|
||||||
setIsCheckingName(true);
|
|
||||||
try {
|
|
||||||
const result = await IpcClient.getInstance().checkAppName({
|
|
||||||
appName: name,
|
|
||||||
});
|
|
||||||
setNameExists(result.exists);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
showError("Failed to check app name: " + (error as any).toString());
|
|
||||||
} finally {
|
|
||||||
setIsCheckingName(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const selectFolderMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const result = await IpcClient.getInstance().selectAppFolder();
|
|
||||||
if (!result.path || !result.name) {
|
|
||||||
throw new Error("No folder selected");
|
|
||||||
}
|
|
||||||
const aiRulesCheck = await IpcClient.getInstance().checkAiRules({
|
|
||||||
path: result.path,
|
|
||||||
});
|
|
||||||
setHasAiRules(aiRulesCheck.exists);
|
|
||||||
setSelectedPath(result.path);
|
|
||||||
// Use the folder name from the IPC response
|
|
||||||
setCustomAppName(result.name);
|
|
||||||
// Check if the app name already exists
|
|
||||||
await checkAppName(result.name);
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
onError: (error: Error) => {
|
|
||||||
showError(error.message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const importAppMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
if (!selectedPath) throw new Error("No folder selected");
|
|
||||||
return IpcClient.getInstance().importApp({
|
|
||||||
path: selectedPath,
|
|
||||||
appName: customAppName,
|
|
||||||
installCommand: installCommand || undefined,
|
|
||||||
startCommand: startCommand || undefined,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: async (result) => {
|
|
||||||
showSuccess(
|
|
||||||
!hasAiRules
|
|
||||||
? "App imported successfully. Dyad will automatically generate an AI_RULES.md now."
|
|
||||||
: "App imported successfully",
|
|
||||||
);
|
|
||||||
onClose();
|
|
||||||
|
|
||||||
navigate({ to: "/chat", search: { id: result.chatId } });
|
|
||||||
if (!hasAiRules) {
|
|
||||||
streamMessage({
|
|
||||||
prompt: AI_RULES_PROMPT,
|
|
||||||
chatId: result.chatId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setSelectedAppId(result.appId);
|
|
||||||
await refreshApps();
|
|
||||||
},
|
|
||||||
onError: (error: Error) => {
|
|
||||||
showError(error.message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSelectFolder = () => {
|
|
||||||
selectFolderMutation.mutate();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImport = () => {
|
|
||||||
importAppMutation.mutate();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClear = () => {
|
|
||||||
setSelectedPath(null);
|
|
||||||
setHasAiRules(null);
|
|
||||||
setCustomAppName("");
|
|
||||||
setNameExists(false);
|
|
||||||
setInstallCommand("");
|
|
||||||
setStartCommand("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAppNameChange = async (
|
|
||||||
e: React.ChangeEvent<HTMLInputElement>,
|
|
||||||
) => {
|
|
||||||
const newName = e.target.value;
|
|
||||||
setCustomAppName(newName);
|
|
||||||
if (newName.trim()) {
|
|
||||||
await checkAppName(newName);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasInstallCommand = installCommand.trim().length > 0;
|
|
||||||
const hasStartCommand = startCommand.trim().length > 0;
|
|
||||||
const commandsValid = hasInstallCommand === hasStartCommand;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
||||||
<DialogContent className="max-w-2xl w-[calc(100vw-2rem)] max-h-[98vh] overflow-y-auto flex flex-col p-0">
|
|
||||||
<DialogHeader className="sticky top-0 bg-background border-b px-6 py-4">
|
|
||||||
<DialogTitle>Import App</DialogTitle>
|
|
||||||
<DialogDescription className="text-sm">
|
|
||||||
Import existing app from local folder or clone from Github.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="px-6 pb-6 overflow-y-auto flex-1">
|
|
||||||
<Alert className="border-blue-500/20 text-blue-500 mb-2">
|
|
||||||
<Info className="h-4 w-4 flex-shrink-0" />
|
|
||||||
<AlertDescription className="text-xs sm:text-sm">
|
|
||||||
App import is an experimental feature. If you encounter any
|
|
||||||
issues, please report them using the Help button.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
<Tabs defaultValue="local-folder" className="w-full">
|
|
||||||
<TabsList className="grid w-full grid-cols-3 h-auto">
|
|
||||||
<TabsTrigger
|
|
||||||
value="local-folder"
|
|
||||||
className="text-xs sm:text-sm px-2 py-2"
|
|
||||||
>
|
|
||||||
Local Folder
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="github-repos"
|
|
||||||
className="text-xs sm:text-sm px-2 py-2"
|
|
||||||
>
|
|
||||||
<span className="hidden sm:inline">Your GitHub Repos</span>
|
|
||||||
<span className="sm:hidden">GitHub Repos</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="github-url"
|
|
||||||
className="text-xs sm:text-sm px-2 py-2"
|
|
||||||
>
|
|
||||||
GitHub URL
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="local-folder" className="space-y-4">
|
|
||||||
<div className="py-4">
|
|
||||||
{!selectedPath ? (
|
|
||||||
<Button
|
|
||||||
onClick={handleSelectFolder}
|
|
||||||
disabled={selectFolderMutation.isPending}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{selectFolderMutation.isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Folder className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{selectFolderMutation.isPending
|
|
||||||
? "Selecting folder..."
|
|
||||||
: "Select Folder"}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-md border p-3 sm:p-4">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="min-w-0 flex-1 overflow-hidden">
|
|
||||||
<p className="text-sm font-medium mb-1">
|
|
||||||
Selected folder:
|
|
||||||
</p>
|
|
||||||
<p className="text-xs sm:text-sm text-muted-foreground break-words">
|
|
||||||
{selectedPath}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleClear}
|
|
||||||
className="h-8 w-8 p-0 flex-shrink-0"
|
|
||||||
disabled={importAppMutation.isPending}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Clear selection</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{nameExists && (
|
|
||||||
<p className="text-xs sm:text-sm text-yellow-500">
|
|
||||||
An app with this name already exists. Please choose a
|
|
||||||
different name:
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="relative">
|
|
||||||
<Label className="text-xs sm:text-sm ml-2 mb-2">
|
|
||||||
App name
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={customAppName}
|
|
||||||
onChange={handleAppNameChange}
|
|
||||||
placeholder="Enter new app name"
|
|
||||||
className="w-full pr-8 text-sm"
|
|
||||||
disabled={importAppMutation.isPending}
|
|
||||||
/>
|
|
||||||
{isCheckingName && (
|
|
||||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Accordion type="single" collapsible>
|
|
||||||
<AccordionItem value="advanced-options">
|
|
||||||
<AccordionTrigger className="text-xs sm:text-sm hover:no-underline">
|
|
||||||
Advanced options
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="space-y-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-xs sm:text-sm ml-2 mb-2">
|
|
||||||
Install command
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={installCommand}
|
|
||||||
onChange={(e) =>
|
|
||||||
setInstallCommand(e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="pnpm install"
|
|
||||||
className="text-sm"
|
|
||||||
disabled={importAppMutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-xs sm:text-sm ml-2 mb-2">
|
|
||||||
Start command
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={startCommand}
|
|
||||||
onChange={(e) => setStartCommand(e.target.value)}
|
|
||||||
placeholder="pnpm dev"
|
|
||||||
className="text-sm"
|
|
||||||
disabled={importAppMutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{!commandsValid && (
|
|
||||||
<p className="text-xs sm:text-sm text-red-500">
|
|
||||||
Both commands are required when customizing.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
{hasAiRules === false && (
|
|
||||||
<Alert className="border-yellow-500/20 text-yellow-500 flex items-start gap-2">
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Info className="h-4 w-4 flex-shrink-0 mt-1" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p className="text-xs">
|
|
||||||
AI_RULES.md lets Dyad know which tech stack to
|
|
||||||
use for editing the app
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
<AlertDescription className="text-xs sm:text-sm">
|
|
||||||
No AI_RULES.md found. Dyad will automatically generate
|
|
||||||
one after importing.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{importAppMutation.isPending && (
|
|
||||||
<div className="flex items-center justify-center space-x-2 text-xs sm:text-sm text-muted-foreground animate-pulse">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
<span>Importing app...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={importAppMutation.isPending}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleImport}
|
|
||||||
disabled={
|
|
||||||
!selectedPath ||
|
|
||||||
importAppMutation.isPending ||
|
|
||||||
nameExists ||
|
|
||||||
!commandsValid
|
|
||||||
}
|
|
||||||
className="w-full sm:w-auto min-w-[80px]"
|
|
||||||
>
|
|
||||||
{importAppMutation.isPending ? <>Importing...</> : "Import"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="github-repos" className="space-y-4">
|
|
||||||
{!isAuthenticated ? (
|
|
||||||
<UnconnectedGitHubConnector
|
|
||||||
appId={null}
|
|
||||||
folderName=""
|
|
||||||
settings={settings}
|
|
||||||
refreshSettings={refreshSettings}
|
|
||||||
handleRepoSetupComplete={() => undefined}
|
|
||||||
expanded={false}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{loading && (
|
|
||||||
<div className="flex justify-center py-8">
|
|
||||||
<Loader2 className="animate-spin h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs sm:text-sm ml-2 mb-2">
|
|
||||||
App name (optional)
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={githubAppName}
|
|
||||||
onChange={handleGithubAppNameChange}
|
|
||||||
placeholder="Leave empty to use repository name"
|
|
||||||
className="w-full pr-8 text-sm"
|
|
||||||
disabled={importing}
|
|
||||||
/>
|
|
||||||
{isCheckingGithubName && (
|
|
||||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{githubNameExists && (
|
|
||||||
<p className="text-xs sm:text-sm text-yellow-500">
|
|
||||||
An app with this name already exists. Please choose a
|
|
||||||
different name.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col space-y-2 max-h-64 overflow-y-auto overflow-x-hidden">
|
|
||||||
{!loading && repos.length === 0 && (
|
|
||||||
<p className="text-xs sm:text-sm text-muted-foreground text-center py-4">
|
|
||||||
No repositories found
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{repos.map((repo) => (
|
|
||||||
<div
|
|
||||||
key={repo.full_name}
|
|
||||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-accent/50 transition-colors min-w-0"
|
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1 overflow-hidden mr-2">
|
|
||||||
<p className="font-semibold truncate text-sm">
|
|
||||||
{repo.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
|
||||||
{repo.full_name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleSelectRepo(repo)}
|
|
||||||
disabled={importing}
|
|
||||||
className="flex-shrink-0 text-xs"
|
|
||||||
>
|
|
||||||
{importing ? (
|
|
||||||
<Loader2 className="animate-spin h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
"Import"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{repos.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Accordion type="single" collapsible>
|
|
||||||
<AccordionItem value="advanced-options">
|
|
||||||
<AccordionTrigger className="text-xs sm:text-sm hover:no-underline">
|
|
||||||
Advanced options
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="space-y-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-xs sm:text-sm">
|
|
||||||
Install command
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={installCommand}
|
|
||||||
onChange={(e) =>
|
|
||||||
setInstallCommand(e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="pnpm install"
|
|
||||||
className="text-sm"
|
|
||||||
disabled={importing}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-xs sm:text-sm">
|
|
||||||
Start command
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={startCommand}
|
|
||||||
onChange={(e) =>
|
|
||||||
setStartCommand(e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="pnpm dev"
|
|
||||||
className="text-sm"
|
|
||||||
disabled={importing}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{!commandsValid && (
|
|
||||||
<p className="text-xs sm:text-sm text-red-500">
|
|
||||||
Both commands are required when customizing.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="github-url" className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs sm:text-sm">Repository URL</Label>
|
|
||||||
<Input
|
|
||||||
placeholder="https://github.com/user/repo.git"
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
|
||||||
disabled={importing}
|
|
||||||
onBlur={handleUrlBlur}
|
|
||||||
className="text-sm break-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs sm:text-sm">
|
|
||||||
App name (optional)
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={githubAppName}
|
|
||||||
onChange={handleGithubAppNameChange}
|
|
||||||
placeholder="Leave empty to use repository name"
|
|
||||||
disabled={importing}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
{isCheckingGithubName && (
|
|
||||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{githubNameExists && (
|
|
||||||
<p className="text-xs sm:text-sm text-yellow-500">
|
|
||||||
An app with this name already exists. Please choose a
|
|
||||||
different name.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Accordion type="single" collapsible>
|
|
||||||
<AccordionItem value="advanced-options">
|
|
||||||
<AccordionTrigger className="text-xs sm:text-sm hover:no-underline">
|
|
||||||
Advanced options
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="space-y-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-xs sm:text-sm">
|
|
||||||
Install command
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={installCommand}
|
|
||||||
onChange={(e) => setInstallCommand(e.target.value)}
|
|
||||||
placeholder="pnpm install"
|
|
||||||
className="text-sm"
|
|
||||||
disabled={importing}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-xs sm:text-sm">
|
|
||||||
Start command
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={startCommand}
|
|
||||||
onChange={(e) => setStartCommand(e.target.value)}
|
|
||||||
placeholder="pnpm dev"
|
|
||||||
className="text-sm"
|
|
||||||
disabled={importing}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{!commandsValid && (
|
|
||||||
<p className="text-xs sm:text-sm text-red-500">
|
|
||||||
Both commands are required when customizing.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleImportFromUrl}
|
|
||||||
disabled={importing || !url.trim() || !commandsValid}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{importing ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="animate-spin mr-2 h-4 w-4" />
|
|
||||||
Importing...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Import"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { X, AlertTriangle } from "lucide-react";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
|
|
||||||
interface InputRequestToastProps {
|
|
||||||
message: string;
|
|
||||||
toastId: string | number;
|
|
||||||
onResponse: (response: "y" | "n") => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InputRequestToast({
|
|
||||||
message,
|
|
||||||
toastId,
|
|
||||||
onResponse,
|
|
||||||
}: InputRequestToastProps) {
|
|
||||||
const handleClose = () => {
|
|
||||||
toast.dismiss(toastId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResponse = (response: "y" | "n") => {
|
|
||||||
onResponse(response);
|
|
||||||
toast.dismiss(toastId);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Clean up the message by removing excessive newlines and whitespace
|
|
||||||
const cleanMessage = message
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative bg-amber-50/95 dark:bg-slate-800/95 backdrop-blur-sm border border-amber-200 dark:border-slate-600 rounded-xl shadow-lg min-w-[400px] max-w-[500px] overflow-hidden">
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-5">
|
|
||||||
<div className="flex items-start">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center mb-4">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="w-6 h-6 bg-gradient-to-br from-amber-500 to-amber-600 dark:from-amber-400 dark:to-amber-500 rounded-full flex items-center justify-center shadow-sm">
|
|
||||||
<AlertTriangle className="w-3.5 h-3.5 text-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h3 className="ml-3 text-base font-semibold text-amber-900 dark:text-amber-100">
|
|
||||||
Input Required
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* Close button */}
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
className="ml-auto flex-shrink-0 p-1.5 text-amber-500 dark:text-slate-400 hover:text-amber-700 dark:hover:text-slate-200 transition-colors duration-200 rounded-md hover:bg-amber-100/50 dark:hover:bg-slate-700/50"
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Message */}
|
|
||||||
<div className="mb-5">
|
|
||||||
<p className="text-sm text-amber-900 dark:text-slate-200 whitespace-pre-wrap leading-relaxed">
|
|
||||||
{cleanMessage}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button
|
|
||||||
onClick={() => handleResponse("y")}
|
|
||||||
size="sm"
|
|
||||||
className="bg-primary text-white dark:bg-primary dark:text-black px-6"
|
|
||||||
>
|
|
||||||
Yes
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => handleResponse("n")}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="border-amber-300 dark:border-slate-500 text-amber-800 dark:text-slate-300 hover:bg-amber-100 dark:hover:bg-slate-700 px-6"
|
|
||||||
>
|
|
||||||
No
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
|
|
||||||
const customLink = ({
|
|
||||||
node: _node,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
node?: any;
|
|
||||||
[key: string]: any;
|
|
||||||
}) => (
|
|
||||||
<a
|
|
||||||
{...props}
|
|
||||||
onClick={(e) => {
|
|
||||||
const url = props.href;
|
|
||||||
if (url) {
|
|
||||||
e.preventDefault();
|
|
||||||
IpcClient.getInstance().openExternalUrl(url);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const VanillaMarkdownParser = ({ content }: { content: string }) => {
|
|
||||||
return (
|
|
||||||
<ReactMarkdown
|
|
||||||
components={{
|
|
||||||
a: customLink,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</ReactMarkdown>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Chat loader with human-like typing/deleting of rotating messages
|
|
||||||
function ChatLoader() {
|
|
||||||
const [currentTextIndex, setCurrentTextIndex] = useState(0);
|
|
||||||
const [displayText, setDisplayText] = useState("");
|
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
|
||||||
const [typingSpeed, setTypingSpeed] = useState(100);
|
|
||||||
|
|
||||||
const loadingTexts = [
|
|
||||||
"Preparing your conversation... 🗨️",
|
|
||||||
"Gathering thoughts... 💭",
|
|
||||||
"Crafting the perfect response... 🎨",
|
|
||||||
"Almost there... 🚀",
|
|
||||||
"Just a moment... ⏳",
|
|
||||||
"Warming up the neural networks... 🧠",
|
|
||||||
"Connecting the dots... 🔗",
|
|
||||||
"Brewing some digital magic... ✨",
|
|
||||||
"Assembling words with care... 🔤",
|
|
||||||
"Fine-tuning the response... 🎯",
|
|
||||||
"Diving into deep thought... 🤿",
|
|
||||||
"Weaving ideas together... 🕸️",
|
|
||||||
"Sparking up the conversation... ⚡",
|
|
||||||
"Polishing the perfect reply... 💎",
|
|
||||||
];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const currentText = loadingTexts[currentTextIndex];
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
if (!isDeleting) {
|
|
||||||
if (displayText.length < currentText.length) {
|
|
||||||
setDisplayText(currentText.substring(0, displayText.length + 1));
|
|
||||||
const randomSpeed = Math.random() * 50 + 30;
|
|
||||||
const isLongPause = Math.random() > 0.85;
|
|
||||||
setTypingSpeed(isLongPause ? 300 : randomSpeed);
|
|
||||||
} else {
|
|
||||||
setTypingSpeed(1500);
|
|
||||||
setIsDeleting(true);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (displayText.length > 0) {
|
|
||||||
setDisplayText(currentText.substring(0, displayText.length - 1));
|
|
||||||
setTypingSpeed(30);
|
|
||||||
} else {
|
|
||||||
setIsDeleting(false);
|
|
||||||
setCurrentTextIndex((prev) => (prev + 1) % loadingTexts.length);
|
|
||||||
setTypingSpeed(500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, typingSpeed);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [displayText, isDeleting, currentTextIndex, typingSpeed]);
|
|
||||||
|
|
||||||
const renderFadingText = () => {
|
|
||||||
return displayText.split("").map((char, index) => {
|
|
||||||
const opacity = Math.min(
|
|
||||||
0.8 + (index / (displayText.length || 1)) * 0.2,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
const isEmoji = /\p{Emoji}/u.test(char);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={index}
|
|
||||||
style={{ opacity }}
|
|
||||||
className={isEmoji ? "inline-block animate-emoji-bounce" : ""}
|
|
||||||
>
|
|
||||||
{char}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center p-4">
|
|
||||||
<style>{`
|
|
||||||
@keyframes blink { from, to { opacity: 0 } 50% { opacity: 1 } }
|
|
||||||
@keyframes emoji-bounce { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(-2px) } }
|
|
||||||
@keyframes text-pulse { 0%, 100% { opacity: .85 } 50% { opacity: 1 } }
|
|
||||||
.animate-blink { animation: blink 1s steps(2, start) infinite; }
|
|
||||||
.animate-emoji-bounce { animation: emoji-bounce 1.2s ease-in-out infinite; }
|
|
||||||
.animate-text-pulse { animation: text-pulse 1.8s ease-in-out infinite; }
|
|
||||||
`}</style>
|
|
||||||
<div className="text-center animate-text-pulse">
|
|
||||||
<div className="inline-block">
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 font-medium">
|
|
||||||
{renderFadingText()}
|
|
||||||
<span className="ml-1 inline-block w-2 h-4 bg-gray-500 dark:bg-gray-400 animate-blink" />
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LoadingBlockProps {
|
|
||||||
isStreaming?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Instead of showing raw thinking content, render the chat loader while streaming.
|
|
||||||
export function LoadingBlock({ isStreaming = false }: LoadingBlockProps) {
|
|
||||||
if (!isStreaming) return null;
|
|
||||||
return <ChatLoader />;
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { MAX_CHAT_TURNS_IN_CONTEXT } from "@/constants/settings_constants";
|
|
||||||
|
|
||||||
interface OptionInfo {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultValue = "default";
|
|
||||||
|
|
||||||
const options: OptionInfo[] = [
|
|
||||||
{
|
|
||||||
value: "2",
|
|
||||||
label: "Economy (2)",
|
|
||||||
description:
|
|
||||||
"Minimal context to reduce token usage and improve response times.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: defaultValue,
|
|
||||||
label: `Default (${MAX_CHAT_TURNS_IN_CONTEXT}) `,
|
|
||||||
description: "Balanced context size for most conversations.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "5",
|
|
||||||
label: "Plus (5)",
|
|
||||||
description: "Slightly higher context size for detailed conversations.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "10",
|
|
||||||
label: "High (10)",
|
|
||||||
description:
|
|
||||||
"Extended context for complex conversations requiring more history.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "100",
|
|
||||||
label: "Max (100)",
|
|
||||||
description: "Maximum context (not recommended due to cost and speed).",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const MaxChatTurnsSelector: React.FC = () => {
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
|
|
||||||
const handleValueChange = (value: string) => {
|
|
||||||
if (value === "default") {
|
|
||||||
updateSettings({ maxChatTurnsInContext: undefined });
|
|
||||||
} else {
|
|
||||||
const numValue = parseInt(value, 10);
|
|
||||||
updateSettings({ maxChatTurnsInContext: numValue });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Determine the current value
|
|
||||||
const currentValue =
|
|
||||||
settings?.maxChatTurnsInContext?.toString() || defaultValue;
|
|
||||||
|
|
||||||
// Find the current option to display its description
|
|
||||||
const currentOption =
|
|
||||||
options.find((opt) => opt.value === currentValue) || options[1];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<label
|
|
||||||
htmlFor="max-chat-turns"
|
|
||||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Maximum number of chat turns used in context
|
|
||||||
</label>
|
|
||||||
<Select value={currentValue} onValueChange={handleValueChange}>
|
|
||||||
<SelectTrigger className="w-[180px]" id="max-chat-turns">
|
|
||||||
<SelectValue placeholder="Select turns" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{options.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{currentOption.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import { X, ShieldAlert } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface McpConsentToastProps {
|
|
||||||
toastId: string | number;
|
|
||||||
serverName: string;
|
|
||||||
toolName: string;
|
|
||||||
toolDescription?: string | null;
|
|
||||||
inputPreview?: string | null;
|
|
||||||
onDecision: (decision: "accept-once" | "accept-always" | "decline") => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function McpConsentToast({
|
|
||||||
toastId,
|
|
||||||
serverName,
|
|
||||||
toolName,
|
|
||||||
toolDescription,
|
|
||||||
inputPreview,
|
|
||||||
onDecision,
|
|
||||||
}: McpConsentToastProps) {
|
|
||||||
const handleClose = () => toast.dismiss(toastId);
|
|
||||||
|
|
||||||
const handle = (d: "accept-once" | "accept-always" | "decline") => {
|
|
||||||
onDecision(d);
|
|
||||||
toast.dismiss(toastId);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Collapsible tool description state
|
|
||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
|
||||||
const [collapsedMaxHeight, setCollapsedMaxHeight] = React.useState<number>(0);
|
|
||||||
const [hasOverflow, setHasOverflow] = React.useState(false);
|
|
||||||
const descRef = React.useRef<HTMLParagraphElement | null>(null);
|
|
||||||
|
|
||||||
// Collapsible input preview state
|
|
||||||
const [isInputExpanded, setIsInputExpanded] = React.useState(false);
|
|
||||||
const [inputCollapsedMaxHeight, setInputCollapsedMaxHeight] =
|
|
||||||
React.useState<number>(0);
|
|
||||||
const [inputHasOverflow, setInputHasOverflow] = React.useState(false);
|
|
||||||
const inputRef = React.useRef<HTMLPreElement | null>(null);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!toolDescription) {
|
|
||||||
setHasOverflow(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const element = descRef.current;
|
|
||||||
if (!element) return;
|
|
||||||
|
|
||||||
const compute = () => {
|
|
||||||
const computedStyle = window.getComputedStyle(element);
|
|
||||||
const lineHeight = parseFloat(computedStyle.lineHeight || "20");
|
|
||||||
const maxLines = 4; // show first few lines by default
|
|
||||||
const maxHeightPx = Math.max(0, Math.round(lineHeight * maxLines));
|
|
||||||
setCollapsedMaxHeight(maxHeightPx);
|
|
||||||
// Overflow if full height exceeds our collapsed height
|
|
||||||
setHasOverflow(element.scrollHeight > maxHeightPx + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Compute initially and on resize
|
|
||||||
compute();
|
|
||||||
const onResize = () => compute();
|
|
||||||
window.addEventListener("resize", onResize);
|
|
||||||
return () => window.removeEventListener("resize", onResize);
|
|
||||||
}, [toolDescription]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!inputPreview) {
|
|
||||||
setInputHasOverflow(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const element = inputRef.current;
|
|
||||||
if (!element) return;
|
|
||||||
|
|
||||||
const compute = () => {
|
|
||||||
const computedStyle = window.getComputedStyle(element);
|
|
||||||
const lineHeight = parseFloat(computedStyle.lineHeight || "16");
|
|
||||||
const maxLines = 6; // show first few lines by default
|
|
||||||
const maxHeightPx = Math.max(0, Math.round(lineHeight * maxLines));
|
|
||||||
setInputCollapsedMaxHeight(maxHeightPx);
|
|
||||||
setInputHasOverflow(element.scrollHeight > maxHeightPx + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
compute();
|
|
||||||
const onResize = () => compute();
|
|
||||||
window.addEventListener("resize", onResize);
|
|
||||||
return () => window.removeEventListener("resize", onResize);
|
|
||||||
}, [inputPreview]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative bg-amber-50/95 dark:bg-slate-800/95 backdrop-blur-sm border border-amber-200 dark:border-slate-600 rounded-xl shadow-lg min-w-[420px] max-w-[560px] overflow-hidden">
|
|
||||||
<div className="p-5">
|
|
||||||
<div className="flex items-start">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center mb-4">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="w-6 h-6 bg-gradient-to-br from-amber-500 to-amber-600 dark:from-amber-400 dark:to-amber-500 rounded-full flex items-center justify-center shadow-sm">
|
|
||||||
<ShieldAlert className="w-3.5 h-3.5 text-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h3 className="ml-3 text-base font-semibold text-amber-900 dark:text-amber-100">
|
|
||||||
Tool wants to run
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
className="ml-auto flex-shrink-0 p-1.5 text-amber-500 dark:text-slate-400 hover:text-amber-700 dark:hover:text-slate-200 transition-colors duration-200 rounded-md hover:bg-amber-100/50 dark:hover:bg-slate-700/50"
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<p>
|
|
||||||
<span className="font-semibold">{toolName}</span> from
|
|
||||||
<span className="font-semibold"> {serverName}</span> requests
|
|
||||||
your consent.
|
|
||||||
</p>
|
|
||||||
{toolDescription && (
|
|
||||||
<div>
|
|
||||||
<p
|
|
||||||
ref={descRef}
|
|
||||||
className="text-muted-foreground whitespace-pre-wrap"
|
|
||||||
style={{
|
|
||||||
maxHeight: isExpanded ? "40vh" : collapsedMaxHeight,
|
|
||||||
overflow: isExpanded ? "auto" : "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{toolDescription}
|
|
||||||
</p>
|
|
||||||
{hasOverflow && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mt-1 text-xs font-medium text-amber-700 hover:underline dark:text-amber-300"
|
|
||||||
onClick={() => setIsExpanded((v) => !v)}
|
|
||||||
>
|
|
||||||
{isExpanded ? "Show less" : "Show more"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{inputPreview && (
|
|
||||||
<div>
|
|
||||||
<pre
|
|
||||||
ref={inputRef}
|
|
||||||
className="bg-amber-100/60 dark:bg-slate-700/60 p-2 rounded text-xs whitespace-pre-wrap"
|
|
||||||
style={{
|
|
||||||
maxHeight: isInputExpanded
|
|
||||||
? "40vh"
|
|
||||||
: inputCollapsedMaxHeight,
|
|
||||||
overflow: isInputExpanded ? "auto" : "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{inputPreview}
|
|
||||||
</pre>
|
|
||||||
{inputHasOverflow && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mt-1 text-xs font-medium text-amber-700 hover:underline dark:text-amber-300"
|
|
||||||
onClick={() => setIsInputExpanded((v) => !v)}
|
|
||||||
>
|
|
||||||
{isInputExpanded ? "Show less" : "Show more"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 mt-4">
|
|
||||||
<Button
|
|
||||||
onClick={() => handle("accept-once")}
|
|
||||||
size="sm"
|
|
||||||
className="px-6"
|
|
||||||
>
|
|
||||||
Allow once
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => handle("accept-always")}
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
className="px-6"
|
|
||||||
>
|
|
||||||
Always allow
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => handle("decline")}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="px-6"
|
|
||||||
>
|
|
||||||
Decline
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Wrench } from "lucide-react";
|
|
||||||
import { useMcp } from "@/hooks/useMcp";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
|
|
||||||
export function McpToolsPicker() {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const { servers, toolsByServer, consentsMap, setToolConsent } = useMcp();
|
|
||||||
|
|
||||||
// Removed activation toggling – consent governs execution time behavior
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="has-[>svg]:px-2"
|
|
||||||
size="sm"
|
|
||||||
data-testid="mcp-tools-button"
|
|
||||||
>
|
|
||||||
<Wrench className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Tools</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
<PopoverContent
|
|
||||||
className="w-120 max-h-[80vh] overflow-y-auto"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium">Tools (MCP)</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Enable tools from your configured MCP servers.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{servers.length === 0 ? (
|
|
||||||
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
|
||||||
No MCP servers configured. Configure them in Settings → Tools
|
|
||||||
(MCP).
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{servers.map((s) => (
|
|
||||||
<div key={s.id} className="border rounded-md p-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="font-medium text-sm truncate">{s.name}</div>
|
|
||||||
{s.enabled ? (
|
|
||||||
<Badge variant="secondary">Enabled</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline">Disabled</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 space-y-1">
|
|
||||||
{(toolsByServer[s.id] || []).map((t) => (
|
|
||||||
<div
|
|
||||||
key={t.name}
|
|
||||||
className="flex items-center justify-between gap-2 rounded border p-2"
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="font-mono text-sm truncate">
|
|
||||||
{t.name}
|
|
||||||
</div>
|
|
||||||
{t.description && (
|
|
||||||
<div className="text-xs text-muted-foreground truncate">
|
|
||||||
{t.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={
|
|
||||||
consentsMap[`${s.id}:${t.name}`] ||
|
|
||||||
t.consent ||
|
|
||||||
"ask"
|
|
||||||
}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setToolConsent(s.id, t.name, v as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[140px] h-8">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="ask">Ask</SelectItem>
|
|
||||||
<SelectItem value="always">Always allow</SelectItem>
|
|
||||||
<SelectItem value="denied">Deny</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{(toolsByServer[s.id] || []).length === 0 && (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
No tools discovered.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,624 +0,0 @@
|
|||||||
import { isDyadProEnabled, type LargeLanguageModel } from "@/lib/schemas";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useLocalModels } from "@/hooks/useLocalModels";
|
|
||||||
import { useLocalLMSModels } from "@/hooks/useLMStudioModels";
|
|
||||||
import { useLanguageModelsByProviders } from "@/hooks/useLanguageModelsByProviders";
|
|
||||||
|
|
||||||
import { LocalModel } from "@/ipc/ipc_types";
|
|
||||||
import { useLanguageModelProviders } from "@/hooks/useLanguageModelProviders";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import { PriceBadge } from "@/components/PriceBadge";
|
|
||||||
import { TURBO_MODELS } from "@/ipc/shared/language_model_constants";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { TOKEN_COUNT_QUERY_KEY } from "@/hooks/useCountTokens";
|
|
||||||
|
|
||||||
export function ModelPicker() {
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const onModelSelect = (model: LargeLanguageModel) => {
|
|
||||||
updateSettings({ selectedModel: model });
|
|
||||||
// Invalidate token count when model changes since different models have different context windows
|
|
||||||
// (technically they have different tokenizers, but we don't keep track of that).
|
|
||||||
queryClient.invalidateQueries({ queryKey: TOKEN_COUNT_QUERY_KEY });
|
|
||||||
};
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
// Cloud models from providers
|
|
||||||
const { data: modelsByProviders, isLoading: modelsByProvidersLoading } =
|
|
||||||
useLanguageModelsByProviders();
|
|
||||||
|
|
||||||
const { data: providers, isLoading: providersLoading } =
|
|
||||||
useLanguageModelProviders();
|
|
||||||
|
|
||||||
const loading = modelsByProvidersLoading || providersLoading;
|
|
||||||
// Ollama Models Hook
|
|
||||||
const {
|
|
||||||
models: ollamaModels,
|
|
||||||
loading: ollamaLoading,
|
|
||||||
error: ollamaError,
|
|
||||||
loadModels: loadOllamaModels,
|
|
||||||
} = useLocalModels();
|
|
||||||
|
|
||||||
// LM Studio Models Hook
|
|
||||||
const {
|
|
||||||
models: lmStudioModels,
|
|
||||||
loading: lmStudioLoading,
|
|
||||||
error: lmStudioError,
|
|
||||||
loadModels: loadLMStudioModels,
|
|
||||||
} = useLocalLMSModels();
|
|
||||||
|
|
||||||
// Load models when the dropdown opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
loadOllamaModels();
|
|
||||||
loadLMStudioModels();
|
|
||||||
}
|
|
||||||
}, [open, loadOllamaModels, loadLMStudioModels]);
|
|
||||||
|
|
||||||
// Get display name for the selected model
|
|
||||||
const getModelDisplayName = () => {
|
|
||||||
if (selectedModel.provider === "ollama") {
|
|
||||||
return (
|
|
||||||
ollamaModels.find(
|
|
||||||
(model: LocalModel) => model.modelName === selectedModel.name,
|
|
||||||
)?.displayName || selectedModel.name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (selectedModel.provider === "lmstudio") {
|
|
||||||
return (
|
|
||||||
lmStudioModels.find(
|
|
||||||
(model: LocalModel) => model.modelName === selectedModel.name,
|
|
||||||
)?.displayName || selectedModel.name // Fallback to path if not found
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For cloud models, look up in the modelsByProviders data
|
|
||||||
if (modelsByProviders && modelsByProviders[selectedModel.provider]) {
|
|
||||||
const customFoundModel = modelsByProviders[selectedModel.provider].find(
|
|
||||||
(model) =>
|
|
||||||
model.type === "custom" && model.id === selectedModel.customModelId,
|
|
||||||
);
|
|
||||||
if (customFoundModel) {
|
|
||||||
return customFoundModel.displayName;
|
|
||||||
}
|
|
||||||
const foundModel = modelsByProviders[selectedModel.provider].find(
|
|
||||||
(model) => model.apiName === selectedModel.name,
|
|
||||||
);
|
|
||||||
if (foundModel) {
|
|
||||||
return foundModel.displayName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback if not found
|
|
||||||
return selectedModel.name;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get auto provider models (if any)
|
|
||||||
const autoModels =
|
|
||||||
!loading && modelsByProviders && modelsByProviders["auto"]
|
|
||||||
? modelsByProviders["auto"].filter((model) => {
|
|
||||||
if (
|
|
||||||
settings &&
|
|
||||||
!isDyadProEnabled(settings) &&
|
|
||||||
["turbo", "value"].includes(model.apiName)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
settings &&
|
|
||||||
isDyadProEnabled(settings) &&
|
|
||||||
model.apiName === "free"
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Determine availability of local models
|
|
||||||
const hasOllamaModels =
|
|
||||||
!ollamaLoading && !ollamaError && ollamaModels.length > 0;
|
|
||||||
const hasLMStudioModels =
|
|
||||||
!lmStudioLoading && !lmStudioError && lmStudioModels.length > 0;
|
|
||||||
|
|
||||||
if (!settings) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const selectedModel = settings?.selectedModel;
|
|
||||||
const modelDisplayName = getModelDisplayName();
|
|
||||||
// Split providers into primary and secondary groups (excluding auto)
|
|
||||||
const providerEntries =
|
|
||||||
!loading && modelsByProviders
|
|
||||||
? Object.entries(modelsByProviders).filter(
|
|
||||||
([providerId]) => providerId !== "auto",
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
const primaryProviders = providerEntries.filter(([providerId, models]) => {
|
|
||||||
if (models.length === 0) return false;
|
|
||||||
const provider = providers?.find((p) => p.id === providerId);
|
|
||||||
return !(provider && provider.secondary);
|
|
||||||
});
|
|
||||||
if (settings && isDyadProEnabled(settings)) {
|
|
||||||
primaryProviders.unshift(["auto", TURBO_MODELS]);
|
|
||||||
}
|
|
||||||
const secondaryProviders = providerEntries.filter(([providerId, models]) => {
|
|
||||||
if (models.length === 0) return false;
|
|
||||||
const provider = providers?.find((p) => p.id === providerId);
|
|
||||||
return !!(provider && provider.secondary);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-2 h-8 max-w-[130px] px-1.5 text-xs-sm"
|
|
||||||
>
|
|
||||||
<span className="truncate">
|
|
||||||
{modelDisplayName === "Auto" && (
|
|
||||||
<>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Model:
|
|
||||||
</span>{" "}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{modelDisplayName}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{modelDisplayName}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<DropdownMenuContent
|
|
||||||
className="w-64"
|
|
||||||
align="start"
|
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<DropdownMenuLabel>Cloud Models</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
{/* Cloud models - loading state */}
|
|
||||||
{loading ? (
|
|
||||||
<div className="text-xs text-center py-2 text-muted-foreground">
|
|
||||||
Loading models...
|
|
||||||
</div>
|
|
||||||
) : !modelsByProviders ||
|
|
||||||
Object.keys(modelsByProviders).length === 0 ? (
|
|
||||||
<div className="text-xs text-center py-2 text-muted-foreground">
|
|
||||||
No cloud models available
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Cloud models loaded */
|
|
||||||
<>
|
|
||||||
{/* Auto models at top level if any */}
|
|
||||||
{autoModels.length > 0 && (
|
|
||||||
<>
|
|
||||||
{autoModels.map((model) => (
|
|
||||||
<Tooltip key={`auto-${model.apiName}`}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className={
|
|
||||||
selectedModel.provider === "auto" &&
|
|
||||||
selectedModel.name === model.apiName
|
|
||||||
? "bg-secondary"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
onModelSelect({
|
|
||||||
name: model.apiName,
|
|
||||||
provider: "auto",
|
|
||||||
});
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start w-full">
|
|
||||||
<span className="flex flex-col items-start">
|
|
||||||
<span>{model.displayName}</span>
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{model.tag && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-[11px] bg-primary/10 text-primary px-1.5 py-0.5 rounded-full font-medium",
|
|
||||||
model.tagColor,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{model.tag}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">
|
|
||||||
{model.description}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
{Object.keys(modelsByProviders).length > 1 && (
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Primary providers as submenus */}
|
|
||||||
{primaryProviders.map(([providerId, models]) => {
|
|
||||||
models = models.filter((model) => {
|
|
||||||
// Don't show free models if Dyad Pro is enabled because
|
|
||||||
// we will use the paid models (in Dyad Pro backend) which
|
|
||||||
// don't have the free limitations.
|
|
||||||
if (
|
|
||||||
isDyadProEnabled(settings) &&
|
|
||||||
model.apiName.endsWith(":free")
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
const provider = providers?.find((p) => p.id === providerId);
|
|
||||||
const providerDisplayName =
|
|
||||||
provider?.id === "auto"
|
|
||||||
? "Dyad Turbo"
|
|
||||||
: (provider?.name ?? providerId);
|
|
||||||
return (
|
|
||||||
<DropdownMenuSub key={providerId}>
|
|
||||||
<DropdownMenuSubTrigger className="w-full font-normal">
|
|
||||||
<div className="flex flex-col items-start w-full">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>{providerDisplayName}</span>
|
|
||||||
{provider?.type === "cloud" &&
|
|
||||||
!provider?.secondary &&
|
|
||||||
isDyadProEnabled(settings) && (
|
|
||||||
<span className="text-[10px] bg-gradient-to-r from-indigo-600 via-indigo-500 to-indigo-600 bg-[length:200%_100%] animate-[shimmer_5s_ease-in-out_infinite] text-white px-1.5 py-0.5 rounded-full font-medium">
|
|
||||||
Pro
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{provider?.type === "custom" && (
|
|
||||||
<span className="text-[10px] bg-amber-500/20 text-amber-700 px-1.5 py-0.5 rounded-full font-medium">
|
|
||||||
Custom
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{models.length} models
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuSubContent className="w-56 max-h-100 overflow-y-auto">
|
|
||||||
<DropdownMenuLabel>
|
|
||||||
{providerDisplayName + " Models"}
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{models.map((model) => (
|
|
||||||
<Tooltip key={`${providerId}-${model.apiName}`}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className={
|
|
||||||
selectedModel.provider === providerId &&
|
|
||||||
selectedModel.name === model.apiName
|
|
||||||
? "bg-secondary"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
const customModelId =
|
|
||||||
model.type === "custom" ? model.id : undefined;
|
|
||||||
onModelSelect({
|
|
||||||
name: model.apiName,
|
|
||||||
provider: providerId,
|
|
||||||
customModelId,
|
|
||||||
});
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start w-full">
|
|
||||||
<span>{model.displayName}</span>
|
|
||||||
<PriceBadge dollarSigns={model.dollarSigns} />
|
|
||||||
{model.tag && (
|
|
||||||
<span className="text-[10px] bg-primary/10 text-primary px-1.5 py-0.5 rounded-full font-medium">
|
|
||||||
{model.tag}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">
|
|
||||||
{model.description}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Secondary providers grouped under Other AI providers */}
|
|
||||||
{secondaryProviders.length > 0 && (
|
|
||||||
<DropdownMenuSub>
|
|
||||||
<DropdownMenuSubTrigger className="w-full font-normal">
|
|
||||||
<div className="flex flex-col items-start">
|
|
||||||
<span>Other AI providers</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{secondaryProviders.length} providers
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuSubContent className="w-56">
|
|
||||||
<DropdownMenuLabel>Other AI providers</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{secondaryProviders.map(([providerId, models]) => {
|
|
||||||
const provider = providers?.find(
|
|
||||||
(p) => p.id === providerId,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<DropdownMenuSub key={providerId}>
|
|
||||||
<DropdownMenuSubTrigger className="w-full font-normal">
|
|
||||||
<div className="flex flex-col items-start w-full">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>{provider?.name ?? providerId}</span>
|
|
||||||
{provider?.type === "custom" && (
|
|
||||||
<span className="text-[10px] bg-amber-500/20 text-amber-700 px-1.5 py-0.5 rounded-full font-medium">
|
|
||||||
Custom
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{models.length} models
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuSubContent className="w-56">
|
|
||||||
<DropdownMenuLabel>
|
|
||||||
{(provider?.name ?? providerId) + " Models"}
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{models.map((model) => (
|
|
||||||
<Tooltip key={`${providerId}-${model.apiName}`}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className={
|
|
||||||
selectedModel.provider === providerId &&
|
|
||||||
selectedModel.name === model.apiName
|
|
||||||
? "bg-secondary"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
const customModelId =
|
|
||||||
model.type === "custom"
|
|
||||||
? model.id
|
|
||||||
: undefined;
|
|
||||||
onModelSelect({
|
|
||||||
name: model.apiName,
|
|
||||||
provider: providerId,
|
|
||||||
customModelId,
|
|
||||||
});
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start w-full">
|
|
||||||
<span>{model.displayName}</span>
|
|
||||||
{model.tag && (
|
|
||||||
<span className="text-[10px] bg-primary/10 text-primary px-1.5 py-0.5 rounded-full font-medium">
|
|
||||||
{model.tag}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">
|
|
||||||
{model.description}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{/* Local Models Parent SubMenu */}
|
|
||||||
<DropdownMenuSub>
|
|
||||||
<DropdownMenuSubTrigger className="w-full font-normal">
|
|
||||||
<div className="flex flex-col items-start">
|
|
||||||
<span>Local models</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
LM Studio, Ollama
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuSubContent className="w-56">
|
|
||||||
{/* Ollama Models SubMenu */}
|
|
||||||
<DropdownMenuSub>
|
|
||||||
<DropdownMenuSubTrigger
|
|
||||||
disabled={ollamaLoading && !hasOllamaModels} // Disable if loading and no models yet
|
|
||||||
className="w-full font-normal"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-start">
|
|
||||||
<span>Ollama</span>
|
|
||||||
{ollamaLoading ? (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Loading...
|
|
||||||
</span>
|
|
||||||
) : ollamaError ? (
|
|
||||||
<span className="text-xs text-red-500">Error loading</span>
|
|
||||||
) : !hasOllamaModels ? (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
None available
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{ollamaModels.length} models
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuSubContent className="w-56 max-h-100 overflow-y-auto">
|
|
||||||
<DropdownMenuLabel>Ollama Models</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
{ollamaLoading && ollamaModels.length === 0 ? ( // Show loading only if no models are loaded yet
|
|
||||||
<div className="text-xs text-center py-2 text-muted-foreground">
|
|
||||||
Loading models...
|
|
||||||
</div>
|
|
||||||
) : ollamaError ? (
|
|
||||||
<div className="px-2 py-1.5 text-sm text-red-600">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>Error loading models</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Is Ollama running?
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : !hasOllamaModels ? (
|
|
||||||
<div className="px-2 py-1.5 text-sm">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>No local models found</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Ensure Ollama is running and models are pulled.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
ollamaModels.map((model: LocalModel) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={`ollama-${model.modelName}`}
|
|
||||||
className={
|
|
||||||
selectedModel.provider === "ollama" &&
|
|
||||||
selectedModel.name === model.modelName
|
|
||||||
? "bg-secondary"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
onModelSelect({
|
|
||||||
name: model.modelName,
|
|
||||||
provider: "ollama",
|
|
||||||
});
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>{model.displayName}</span>
|
|
||||||
<span className="text-xs text-muted-foreground truncate">
|
|
||||||
{model.modelName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
|
|
||||||
{/* LM Studio Models SubMenu */}
|
|
||||||
<DropdownMenuSub>
|
|
||||||
<DropdownMenuSubTrigger
|
|
||||||
disabled={lmStudioLoading && !hasLMStudioModels} // Disable if loading and no models yet
|
|
||||||
className="w-full font-normal"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-start">
|
|
||||||
<span>LM Studio</span>
|
|
||||||
{lmStudioLoading ? (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Loading...
|
|
||||||
</span>
|
|
||||||
) : lmStudioError ? (
|
|
||||||
<span className="text-xs text-red-500">Error loading</span>
|
|
||||||
) : !hasLMStudioModels ? (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
None available
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{lmStudioModels.length} models
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuSubContent className="w-56 max-h-100 overflow-y-auto">
|
|
||||||
<DropdownMenuLabel>LM Studio Models</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
{lmStudioLoading && lmStudioModels.length === 0 ? ( // Show loading only if no models are loaded yet
|
|
||||||
<div className="text-xs text-center py-2 text-muted-foreground">
|
|
||||||
Loading models...
|
|
||||||
</div>
|
|
||||||
) : lmStudioError ? (
|
|
||||||
<div className="px-2 py-1.5 text-sm text-red-600">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>Error loading models</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{lmStudioError.message} {/* Display specific error */}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : !hasLMStudioModels ? (
|
|
||||||
<div className="px-2 py-1.5 text-sm">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>No loaded models found</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Ensure LM Studio is running and models are loaded.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
lmStudioModels.map((model: LocalModel) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={`lmstudio-${model.modelName}`}
|
|
||||||
className={
|
|
||||||
selectedModel.provider === "lmstudio" &&
|
|
||||||
selectedModel.name === model.modelName
|
|
||||||
? "bg-secondary"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
onModelSelect({
|
|
||||||
name: model.modelName,
|
|
||||||
provider: "lmstudio",
|
|
||||||
});
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{/* Display the user-friendly name */}
|
|
||||||
<span>{model.displayName}</span>
|
|
||||||
{/* Show the path as secondary info */}
|
|
||||||
<span className="text-xs text-muted-foreground truncate">
|
|
||||||
{model.modelName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
|
|
||||||
import { useDeepLink } from "@/contexts/DeepLinkContext";
|
|
||||||
import { ExternalLink } from "lucide-react";
|
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
|
||||||
import { NeonDisconnectButton } from "@/components/NeonDisconnectButton";
|
|
||||||
|
|
||||||
export function NeonConnector() {
|
|
||||||
const { settings, refreshSettings } = useSettings();
|
|
||||||
const { lastDeepLink, clearLastDeepLink } = useDeepLink();
|
|
||||||
const { isDarkMode } = useTheme();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleDeepLink = async () => {
|
|
||||||
if (lastDeepLink?.type === "neon-oauth-return") {
|
|
||||||
await refreshSettings();
|
|
||||||
toast.success("Successfully connected to Neon!");
|
|
||||||
clearLastDeepLink();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
handleDeepLink();
|
|
||||||
}, [lastDeepLink?.timestamp]);
|
|
||||||
|
|
||||||
if (settings?.neon?.accessToken) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col space-y-4 p-4 border bg-white dark:bg-gray-800 max-w-100 rounded-md">
|
|
||||||
<div className="flex flex-col items-start justify-between">
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<h2 className="text-lg font-medium pb-1">Neon Database</h2>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
"https://console.neon.tech/",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="ml-2 px-2 py-1 h-8 mb-2"
|
|
||||||
style={{ display: "inline-flex", alignItems: "center" }}
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
Neon
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 pb-3">
|
|
||||||
You are connected to Neon Database
|
|
||||||
</p>
|
|
||||||
<NeonDisconnectButton />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col space-y-4 p-4 border bg-white dark:bg-gray-800 max-w-100 rounded-md">
|
|
||||||
<div className="flex flex-col items-start justify-between">
|
|
||||||
<h2 className="text-lg font-medium pb-1">Neon Database</h2>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 pb-3">
|
|
||||||
Neon Database has a good free tier with backups and up to 10 projects.
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
onClick={async () => {
|
|
||||||
if (settings?.isTestMode) {
|
|
||||||
await IpcClient.getInstance().fakeHandleNeonConnect();
|
|
||||||
} else {
|
|
||||||
await IpcClient.getInstance().openExternalUrl(
|
|
||||||
"https://oauth.dyad.sh/api/integrations/neon/login",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-auto h-10 cursor-pointer flex items-center justify-center px-4 py-2 rounded-md border-2 transition-colors font-medium text-sm dark:bg-gray-900 dark:border-gray-700"
|
|
||||||
data-testid="connect-neon-button"
|
|
||||||
>
|
|
||||||
<span className="mr-2">Connect to</span>
|
|
||||||
<NeonSvg isDarkMode={isDarkMode} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NeonSvg({
|
|
||||||
isDarkMode,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
isDarkMode?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const textColor = isDarkMode ? "#fff" : "#000";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="68"
|
|
||||||
height="18"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 102 28"
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="#12FFF7"
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M0 4.828C0 2.16 2.172 0 4.851 0h18.436c2.679 0 4.85 2.161 4.85 4.828V20.43c0 2.758-3.507 3.955-5.208 1.778l-5.318-6.809v8.256c0 2.4-1.955 4.345-4.367 4.345H4.851C2.172 28 0 25.839 0 23.172zm4.851-.966a.97.97 0 0 0-.97.966v18.344c0 .534.435.966.97.966h8.539c.268 0 .34-.216.34-.483v-11.07c0-2.76 3.507-3.956 5.208-1.779l5.319 6.809V4.828c0-.534.05-.966-.485-.966z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="url(#a)"
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M0 4.828C0 2.16 2.172 0 4.851 0h18.436c2.679 0 4.85 2.161 4.85 4.828V20.43c0 2.758-3.507 3.955-5.208 1.778l-5.318-6.809v8.256c0 2.4-1.955 4.345-4.367 4.345H4.851C2.172 28 0 25.839 0 23.172zm4.851-.966a.97.97 0 0 0-.97.966v18.344c0 .534.435.966.97.966h8.539c.268 0 .34-.216.34-.483v-11.07c0-2.76 3.507-3.956 5.208-1.779l5.319 6.809V4.828c0-.534.05-.966-.485-.966z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="url(#b)"
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M0 4.828C0 2.16 2.172 0 4.851 0h18.436c2.679 0 4.85 2.161 4.85 4.828V20.43c0 2.758-3.507 3.955-5.208 1.778l-5.318-6.809v8.256c0 2.4-1.955 4.345-4.367 4.345H4.851C2.172 28 0 25.839 0 23.172zm4.851-.966a.97.97 0 0 0-.97.966v18.344c0 .534.435.966.97.966h8.539c.268 0 .34-.216.34-.483v-11.07c0-2.76 3.507-3.956 5.208-1.779l5.319 6.809V4.828c0-.534.05-.966-.485-.966z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="#B9FFB3"
|
|
||||||
d="M23.287 0c2.679 0 4.85 2.161 4.85 4.828V20.43c0 2.758-3.507 3.955-5.208 1.778l-5.319-6.809v8.256c0 2.4-1.954 4.345-4.366 4.345a.484.484 0 0 0 .485-.483V12.584c0-2.758 3.508-3.955 5.21-1.777l5.318 6.808V.965a.97.97 0 0 0-.97-.965"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill={textColor}
|
|
||||||
d="M48.112 7.432v8.032l-7.355-8.032H36.93v13.136h3.49v-8.632l8.01 8.632h3.173V7.432zM58.075 17.64v-2.326h7.815v-2.797h-7.815V10.36h9.48V7.432H54.514v13.136H67.75v-2.927zM77.028 21c4.909 0 8.098-2.552 8.098-7s-3.19-7-8.098-7c-4.91 0-8.081 2.552-8.081 7s3.172 7 8.08 7m0-3.115c-2.73 0-4.413-1.408-4.413-3.885s1.701-3.885 4.413-3.885c2.729 0 4.412 1.408 4.412 3.885s-1.683 3.885-4.412 3.885M98.508 7.432v8.032l-7.355-8.032h-3.828v13.136h3.491v-8.632l8.01 8.632H102V7.432z"
|
|
||||||
/>
|
|
||||||
<defs>
|
|
||||||
<linearGradient
|
|
||||||
id="a"
|
|
||||||
x1="28.138"
|
|
||||||
x2="3.533"
|
|
||||||
y1="28"
|
|
||||||
y2="-.12"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
>
|
|
||||||
<stop stopColor="#B9FFB3" />
|
|
||||||
<stop offset="1" stopColor="#B9FFB3" stopOpacity="0" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient
|
|
||||||
id="b"
|
|
||||||
x1="28.138"
|
|
||||||
x2="11.447"
|
|
||||||
y1="28"
|
|
||||||
y2="21.476"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
>
|
|
||||||
<stop stopColor="#1A1A1A" stopOpacity=".9" />
|
|
||||||
<stop offset="1" stopColor="#1A1A1A" stopOpacity="0" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
|
|
||||||
interface NeonDisconnectButtonProps {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NeonDisconnectButton({ className }: NeonDisconnectButtonProps) {
|
|
||||||
const { updateSettings, settings } = useSettings();
|
|
||||||
|
|
||||||
const handleDisconnect = async () => {
|
|
||||||
try {
|
|
||||||
await updateSettings({
|
|
||||||
neon: undefined,
|
|
||||||
});
|
|
||||||
toast.success("Disconnected from Neon successfully");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to disconnect from Neon:", error);
|
|
||||||
toast.error("Failed to disconnect from Neon");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!settings?.neon?.accessToken) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleDisconnect}
|
|
||||||
className={className}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Disconnect from Neon
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import { NeonDisconnectButton } from "@/components/NeonDisconnectButton";
|
|
||||||
|
|
||||||
export function NeonIntegration() {
|
|
||||||
const { settings } = useSettings();
|
|
||||||
|
|
||||||
const isConnected = !!settings?.neon?.accessToken;
|
|
||||||
|
|
||||||
if (!isConnected) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Neon Integration
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
Your account is connected to Neon.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<NeonDisconnectButton />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import { showError, showSuccess } from "@/lib/toast";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
import { FolderOpen, RotateCcw, CheckCircle, AlertCircle } from "lucide-react";
|
|
||||||
|
|
||||||
export function NodePathSelector() {
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
const [isSelectingPath, setIsSelectingPath] = useState(false);
|
|
||||||
const [nodeStatus, setNodeStatus] = useState<{
|
|
||||||
version: string | null;
|
|
||||||
isValid: boolean;
|
|
||||||
}>({
|
|
||||||
version: null,
|
|
||||||
isValid: false,
|
|
||||||
});
|
|
||||||
const [isCheckingNode, setIsCheckingNode] = useState(false);
|
|
||||||
const [systemPath, setSystemPath] = useState<string>("Loading...");
|
|
||||||
|
|
||||||
// Check Node.js status when component mounts or path changes
|
|
||||||
useEffect(() => {
|
|
||||||
checkNodeStatus();
|
|
||||||
}, [settings?.customNodePath]);
|
|
||||||
|
|
||||||
const fetchSystemPath = async () => {
|
|
||||||
try {
|
|
||||||
const debugInfo = await IpcClient.getInstance().getSystemDebugInfo();
|
|
||||||
setSystemPath(debugInfo.nodePath || "System PATH (not available)");
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to fetch system path:", err);
|
|
||||||
setSystemPath("System PATH (not available)");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Fetch system path on mount
|
|
||||||
fetchSystemPath();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const checkNodeStatus = async () => {
|
|
||||||
if (!settings) return;
|
|
||||||
setIsCheckingNode(true);
|
|
||||||
try {
|
|
||||||
const status = await IpcClient.getInstance().getNodejsStatus();
|
|
||||||
setNodeStatus({
|
|
||||||
version: status.nodeVersion,
|
|
||||||
isValid: !!status.nodeVersion,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to check Node.js status:", error);
|
|
||||||
setNodeStatus({ version: null, isValid: false });
|
|
||||||
} finally {
|
|
||||||
setIsCheckingNode(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleSelectNodePath = async () => {
|
|
||||||
setIsSelectingPath(true);
|
|
||||||
try {
|
|
||||||
// Call the IPC method to select folder
|
|
||||||
const result = await IpcClient.getInstance().selectNodeFolder();
|
|
||||||
if (result.path) {
|
|
||||||
// Save the custom path to settings
|
|
||||||
await updateSettings({ customNodePath: result.path });
|
|
||||||
// Update the environment PATH
|
|
||||||
await IpcClient.getInstance().reloadEnvPath();
|
|
||||||
// Recheck Node.js status
|
|
||||||
await checkNodeStatus();
|
|
||||||
showSuccess("Node.js path updated successfully");
|
|
||||||
} else if (result.path === null && result.canceled === false) {
|
|
||||||
showError(
|
|
||||||
`Could not find Node.js at the path "${result.selectedPath}"`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
showError(`Failed to set Node.js path: ${error.message}`);
|
|
||||||
} finally {
|
|
||||||
setIsSelectingPath(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleResetToDefault = async () => {
|
|
||||||
try {
|
|
||||||
// Clear the custom path
|
|
||||||
await updateSettings({ customNodePath: null });
|
|
||||||
// Reload environment to use system PATH
|
|
||||||
await IpcClient.getInstance().reloadEnvPath();
|
|
||||||
// Recheck Node.js status
|
|
||||||
await fetchSystemPath();
|
|
||||||
await checkNodeStatus();
|
|
||||||
showSuccess("Reset to system Node.js path");
|
|
||||||
} catch (error: any) {
|
|
||||||
showError(`Failed to reset Node.js path: ${error.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!settings) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const currentPath = settings.customNodePath || systemPath;
|
|
||||||
const isCustomPath = !!settings.customNodePath;
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Label className="text-sm font-medium">
|
|
||||||
Node.js Path Configuration
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleSelectNodePath}
|
|
||||||
disabled={isSelectingPath}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<FolderOpen className="w-4 h-4" />
|
|
||||||
{isSelectingPath ? "Selecting..." : "Browse for Node.js"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{isCustomPath && (
|
|
||||||
<Button
|
|
||||||
onClick={handleResetToDefault}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-4 h-4" />
|
|
||||||
Reset to Default
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
||||||
{isCustomPath ? "Custom Path:" : "System PATH:"}
|
|
||||||
</span>
|
|
||||||
{isCustomPath && (
|
|
||||||
<span className="px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
|
|
||||||
Custom
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-mono text-gray-700 dark:text-gray-300 break-all max-h-32 overflow-y-auto">
|
|
||||||
{currentPath}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Indicator */}
|
|
||||||
<div className="ml-3 flex items-center">
|
|
||||||
{isCheckingNode ? (
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-gray-300 border-t-blue-500" />
|
|
||||||
) : nodeStatus.isValid ? (
|
|
||||||
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
|
||||||
<CheckCircle className="w-4 h-4" />
|
|
||||||
<span className="text-xs">{nodeStatus.version}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-1 text-yellow-600 dark:text-yellow-400">
|
|
||||||
<AlertCircle className="w-4 h-4" />
|
|
||||||
<span className="text-xs">Not found</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Help Text */}
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{nodeStatus.isValid ? (
|
|
||||||
<p>Node.js is properly configured and ready to use.</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p>
|
|
||||||
Select the folder where Node.js is installed if it's not in your
|
|
||||||
system PATH.
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ExternalLink, Database, Loader2 } from "lucide-react";
|
|
||||||
import { showSuccess, showError } from "@/lib/toast";
|
|
||||||
import { useVersions } from "@/hooks/useVersions";
|
|
||||||
|
|
||||||
interface PortalMigrateProps {
|
|
||||||
appId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PortalMigrate = ({ appId }: PortalMigrateProps) => {
|
|
||||||
const [output, setOutput] = useState<string>("");
|
|
||||||
const { refreshVersions } = useVersions(appId);
|
|
||||||
|
|
||||||
const migrateMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const ipcClient = IpcClient.getInstance();
|
|
||||||
return ipcClient.portalMigrateCreate({ appId });
|
|
||||||
},
|
|
||||||
onSuccess: (result) => {
|
|
||||||
setOutput(result.output);
|
|
||||||
showSuccess(
|
|
||||||
"Database migration file generated and committed successfully!",
|
|
||||||
);
|
|
||||||
refreshVersions();
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : String(error);
|
|
||||||
setOutput(`Error: ${errorMessage}`);
|
|
||||||
showError(errorMessage);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleCreateMigration = () => {
|
|
||||||
setOutput(""); // Clear previous output
|
|
||||||
migrateMutation.mutate();
|
|
||||||
};
|
|
||||||
|
|
||||||
const openDocs = () => {
|
|
||||||
const ipcClient = IpcClient.getInstance();
|
|
||||||
ipcClient.openExternalUrl(
|
|
||||||
"https://www.dyad.sh/docs/templates/portal#create-a-database-migration",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Database className="w-5 h-5 text-primary" />
|
|
||||||
Portal Database Migration
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
Generate a new database migration file for your Portal app.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button
|
|
||||||
onClick={handleCreateMigration}
|
|
||||||
disabled={migrateMutation.isPending}
|
|
||||||
// className="bg-primary hover:bg-purple-700 text-white"
|
|
||||||
>
|
|
||||||
{migrateMutation.isPending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Generating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Database className="w-4 h-4 mr-2" />
|
|
||||||
Generate database migration
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={openDocs}
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-3 h-3 mr-1" />
|
|
||||||
Docs
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{output && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-900 border rounded-lg p-3">
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Command Output:
|
|
||||||
</h4>
|
|
||||||
<div className="max-h-64 overflow-auto">
|
|
||||||
<pre className="text-xs text-gray-600 dark:text-gray-400 whitespace-pre-wrap font-mono">
|
|
||||||
{output}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
export function PriceBadge({
|
|
||||||
dollarSigns,
|
|
||||||
}: {
|
|
||||||
dollarSigns: number | undefined;
|
|
||||||
}) {
|
|
||||||
if (dollarSigns === undefined || dollarSigns === null) return null;
|
|
||||||
|
|
||||||
const label = dollarSigns === 0 ? "Free" : "$".repeat(dollarSigns);
|
|
||||||
|
|
||||||
const className =
|
|
||||||
dollarSigns === 0
|
|
||||||
? "text-[10px] text-primary border border-primary px-1.5 py-0.5 rounded-full font-medium"
|
|
||||||
: "text-[10px] bg-primary/10 text-primary px-1.5 py-0.5 rounded-full font-medium";
|
|
||||||
|
|
||||||
return <span className={className}>{label}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PriceBadge;
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
// @ts-ignore
|
|
||||||
import openAiLogo from "../../assets/ai-logos/openai-logo.svg";
|
|
||||||
// @ts-ignore
|
|
||||||
import googleLogo from "../../assets/ai-logos/google-logo.svg";
|
|
||||||
// @ts-ignore
|
|
||||||
import anthropicLogo from "../../assets/ai-logos/anthropic-logo.svg";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { KeyRound } from "lucide-react";
|
|
||||||
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
|
|
||||||
export function ProBanner() {
|
|
||||||
const { settings } = useSettings();
|
|
||||||
const { userBudget } = useUserBudgetInfo();
|
|
||||||
|
|
||||||
const [selectedBanner] = useState<"ai" | "smart" | "turbo">(() => {
|
|
||||||
const options = ["ai", "smart", "turbo"] as const;
|
|
||||||
return options[Math.floor(Math.random() * options.length)];
|
|
||||||
});
|
|
||||||
|
|
||||||
if (settings?.enableDyadPro || userBudget) {
|
|
||||||
return (
|
|
||||||
<div className="mt-6 max-w-2xl mx-auto">
|
|
||||||
<ManageDyadProButton />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-6 max-w-2xl mx-auto">
|
|
||||||
{selectedBanner === "ai" ? (
|
|
||||||
<AiAccessBanner />
|
|
||||||
) : selectedBanner === "smart" ? (
|
|
||||||
<SmartContextBanner />
|
|
||||||
) : (
|
|
||||||
<TurboBanner />
|
|
||||||
)}
|
|
||||||
<SetupDyadProButton />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ManageDyadProButton() {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="lg"
|
|
||||||
className="w-full mt-4 bg-(--background-lighter) text-primary"
|
|
||||||
onClick={() => {
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
"https://academy.dyad.sh/subscription",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<KeyRound aria-hidden="true" />
|
|
||||||
Manage Dyad Pro subscription
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SetupDyadProButton() {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="lg"
|
|
||||||
className="w-full mt-4 bg-(--background-lighter) text-primary"
|
|
||||||
onClick={() => {
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
"https://academy.dyad.sh/settings",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<KeyRound aria-hidden="true" />
|
|
||||||
Already have Dyad Pro? Add your key
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AiAccessBanner() {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="w-full py-2 sm:py-2.5 md:py-3 rounded-lg bg-gradient-to-br from-white via-indigo-50 to-sky-100 dark:from-indigo-700 dark:via-indigo-700 dark:to-indigo-900 flex items-center justify-center relative overflow-hidden ring-1 ring-inset ring-black/5 dark:ring-white/10 shadow-sm cursor-pointer transition-all duration-200 hover:shadow-md hover:-translate-y-[1px]"
|
|
||||||
onClick={() => {
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
"https://www.dyad.sh/pro?utm_source=dyad-app&utm_medium=app&utm_campaign=in-app-banner-ai-access",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 z-0 bg-gradient-to-tr from-white/60 via-transparent to-transparent pointer-events-none dark:from-white/10"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 z-0 pointer-events-none dark:hidden">
|
|
||||||
<div className="absolute -top-8 -left-6 h-40 w-40 rounded-full blur-2xl bg-violet-200/40" />
|
|
||||||
<div className="absolute -bottom-10 -right-6 h-48 w-48 rounded-full blur-3xl bg-sky-200/40" />
|
|
||||||
</div>
|
|
||||||
<div className="relative z-10 text-center flex flex-col items-center gap-0.5 sm:gap-1 md:gap-1.5 px-4 md:px-6 pr-6 md:pr-8">
|
|
||||||
<div className="mt-0.5 sm:mt-1 flex items-center gap-2 sm:gap-3 justify-center">
|
|
||||||
<div className="text-xl font-semibold tracking-tight text-indigo-900 dark:text-indigo-100">
|
|
||||||
Access leading AI models with one plan
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Subscribe to Dyad Pro"
|
|
||||||
className="inline-flex items-center rounded-md bg-white/90 text-indigo-800 hover:bg-white shadow px-3 py-1.5 text-xs sm:text-sm font-semibold focus:outline-none focus:ring-2 focus:ring-white/50"
|
|
||||||
>
|
|
||||||
Get Dyad Pro
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-1.5 sm:mt-2 grid grid-cols-3 gap-6 md:gap-8 items-center justify-items-center opacity-90">
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<img
|
|
||||||
src={openAiLogo}
|
|
||||||
alt="OpenAI"
|
|
||||||
width={96}
|
|
||||||
height={28}
|
|
||||||
className="h-4 md:h-5 w-auto dark:invert"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<img
|
|
||||||
src={googleLogo}
|
|
||||||
alt="Google"
|
|
||||||
width={110}
|
|
||||||
height={30}
|
|
||||||
className="h-4 md:h-5 w-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<img
|
|
||||||
src={anthropicLogo}
|
|
||||||
alt="Anthropic"
|
|
||||||
width={110}
|
|
||||||
height={30}
|
|
||||||
className="h-3 w-auto dark:invert"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SmartContextBanner() {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="w-full py-2 sm:py-2.5 md:py-3 rounded-lg bg-gradient-to-br from-emerald-50 via-emerald-100 to-emerald-200 dark:from-emerald-700 dark:via-emerald-700 dark:to-emerald-900 flex items-center justify-center relative overflow-hidden ring-1 ring-inset ring-emerald-900/10 dark:ring-white/10 shadow-sm cursor-pointer transition-all duration-200 hover:shadow-md hover:-translate-y-[1px]"
|
|
||||||
onClick={() => {
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
"https://www.dyad.sh/pro?utm_source=dyad-app&utm_medium=app&utm_campaign=in-app-banner-smart-context",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 z-0 bg-gradient-to-tr from-white/60 via-transparent to-transparent pointer-events-none dark:from-white/10"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 z-0 pointer-events-none dark:hidden">
|
|
||||||
<div className="absolute -top-10 -left-8 h-44 w-44 rounded-full blur-2xl bg-emerald-200/50" />
|
|
||||||
<div className="absolute -bottom-12 -right-8 h-56 w-56 rounded-full blur-3xl bg-teal-200/50" />
|
|
||||||
</div>
|
|
||||||
<div className="relative z-10 px-4 md:px-6 pr-6 md:pr-8">
|
|
||||||
<div className="mt-0.5 sm:mt-1 flex items-center gap-2 sm:gap-3 justify-center">
|
|
||||||
<div className="flex flex-col items-center text-center">
|
|
||||||
<div className="text-xl font-semibold tracking-tight text-emerald-900 dark:text-emerald-100">
|
|
||||||
Up to 5x cheaper
|
|
||||||
</div>
|
|
||||||
<div className="text-sm sm:text-base mt-1 text-emerald-700 dark:text-emerald-200/80">
|
|
||||||
by using Smart Context
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Get Dyad Pro"
|
|
||||||
className="inline-flex items-center rounded-md bg-white/90 text-emerald-800 hover:bg-white shadow px-3 py-1.5 text-xs sm:text-sm font-semibold focus:outline-none focus:ring-2 focus:ring-white/50"
|
|
||||||
>
|
|
||||||
Get Dyad Pro
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TurboBanner() {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="w-full py-2 sm:py-2.5 md:py-3 rounded-lg bg-gradient-to-br from-rose-50 via-rose-100 to-rose-200 dark:from-rose-800 dark:via-fuchsia-800 dark:to-rose-800 flex items-center justify-center relative overflow-hidden ring-1 ring-inset ring-rose-900/10 dark:ring-white/5 shadow-sm cursor-pointer transition-all duration-200 hover:shadow-md hover:-translate-y-[1px]"
|
|
||||||
onClick={() => {
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
"https://www.dyad.sh/pro?utm_source=dyad-app&utm_medium=app&utm_campaign=in-app-banner-turbo",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 z-0 bg-gradient-to-tr from-white/60 via-transparent to-transparent pointer-events-none dark:from-white/10"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 z-0 pointer-events-none dark:hidden">
|
|
||||||
<div className="absolute -top-10 -left-8 h-44 w-44 rounded-full blur-2xl bg-rose-200/50" />
|
|
||||||
<div className="absolute -bottom-12 -right-8 h-56 w-56 rounded-full blur-3xl bg-fuchsia-200/50" />
|
|
||||||
</div>
|
|
||||||
<div className="relative z-10 px-4 md:px-6 pr-6 md:pr-8">
|
|
||||||
<div className="mt-0.5 sm:mt-1 flex items-center gap-2 sm:gap-3 justify-center">
|
|
||||||
<div className="flex flex-col items-center text-center">
|
|
||||||
<div className="text-xl font-semibold tracking-tight text-rose-900 dark:text-rose-100">
|
|
||||||
Generate code 4–10x faster
|
|
||||||
</div>
|
|
||||||
<div className="text-sm sm:text-base mt-1 text-rose-700 dark:text-rose-200/80">
|
|
||||||
with Turbo Models & Turbo Edits
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Get Dyad Pro"
|
|
||||||
className="inline-flex items-center rounded-md bg-white/90 text-rose-800 hover:bg-white shadow px-3 py-1.5 text-xs sm:text-sm font-semibold focus:outline-none focus:ring-2 focus:ring-white/50"
|
|
||||||
>
|
|
||||||
Get Dyad Pro
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,396 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Sparkles, Info } from "lucide-react";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
import { hasDyadProKey, type UserSettings } from "@/lib/schemas";
|
|
||||||
|
|
||||||
export function ProModeSelector() {
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
|
|
||||||
const toggleWebSearch = () => {
|
|
||||||
updateSettings({
|
|
||||||
enableProWebSearch: !settings?.enableProWebSearch,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTurboEditsChange = (newValue: "off" | "v1" | "v2") => {
|
|
||||||
updateSettings({
|
|
||||||
enableProLazyEditsMode: newValue !== "off",
|
|
||||||
proLazyEditsMode: newValue,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSmartContextChange = (newValue: "off" | "deep" | "balanced") => {
|
|
||||||
if (newValue === "off") {
|
|
||||||
updateSettings({
|
|
||||||
enableProSmartFilesContextMode: false,
|
|
||||||
proSmartContextOption: undefined,
|
|
||||||
});
|
|
||||||
} else if (newValue === "deep") {
|
|
||||||
updateSettings({
|
|
||||||
enableProSmartFilesContextMode: true,
|
|
||||||
proSmartContextOption: "deep",
|
|
||||||
});
|
|
||||||
} else if (newValue === "balanced") {
|
|
||||||
updateSettings({
|
|
||||||
enableProSmartFilesContextMode: true,
|
|
||||||
proSmartContextOption: "balanced",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleProEnabled = () => {
|
|
||||||
updateSettings({
|
|
||||||
enableDyadPro: !settings?.enableDyadPro,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasProKey = settings ? hasDyadProKey(settings) : false;
|
|
||||||
const proModeTogglable = hasProKey && Boolean(settings?.enableDyadPro);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="has-[>svg]:px-1.5 flex items-center gap-1.5 h-8 border-primary/50 hover:bg-primary/10 font-medium shadow-sm shadow-primary/10 transition-all hover:shadow-md hover:shadow-primary/15"
|
|
||||||
>
|
|
||||||
<Sparkles className="h-4 w-4 text-primary" />
|
|
||||||
<span className="text-primary font-medium text-xs-sm">Pro</span>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Configure Dyad Pro settings</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<PopoverContent className="w-80 border-primary/20">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h4 className="font-medium flex items-center gap-1.5">
|
|
||||||
<Sparkles className="h-4 w-4 text-primary" />
|
|
||||||
<span className="text-primary font-medium">Dyad Pro</span>
|
|
||||||
</h4>
|
|
||||||
<div className="h-px bg-gradient-to-r from-primary/50 via-primary/20 to-transparent" />
|
|
||||||
</div>
|
|
||||||
{!hasProKey && (
|
|
||||||
<div className="text-sm text-center text-muted-foreground">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<a
|
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-md border border-primary/30 bg-primary/10 px-3 py-2 text-sm font-medium text-primary shadow-sm transition-colors hover:bg-primary/20 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
"https://dyad.sh/pro#ai",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Unlock Pro modes
|
|
||||||
</a>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
Visit dyad.sh/pro to unlock Pro features
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<SelectorRow
|
|
||||||
id="pro-enabled"
|
|
||||||
label="Enable Dyad Pro"
|
|
||||||
tooltip="Uses Dyad Pro AI credits for the main AI model and Pro modes."
|
|
||||||
isTogglable={hasProKey}
|
|
||||||
settingEnabled={Boolean(settings?.enableDyadPro)}
|
|
||||||
toggle={toggleProEnabled}
|
|
||||||
/>
|
|
||||||
<SelectorRow
|
|
||||||
id="web-search"
|
|
||||||
label="Web Access"
|
|
||||||
tooltip="Allows Dyad to access the web (e.g. search for information)"
|
|
||||||
isTogglable={proModeTogglable}
|
|
||||||
settingEnabled={Boolean(settings?.enableProWebSearch)}
|
|
||||||
toggle={toggleWebSearch}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TurboEditsSelector
|
|
||||||
isTogglable={proModeTogglable}
|
|
||||||
settings={settings}
|
|
||||||
onValueChange={handleTurboEditsChange}
|
|
||||||
/>
|
|
||||||
<SmartContextSelector
|
|
||||||
isTogglable={proModeTogglable}
|
|
||||||
settings={settings}
|
|
||||||
onValueChange={handleSmartContextChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectorRow({
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
tooltip,
|
|
||||||
isTogglable,
|
|
||||||
settingEnabled,
|
|
||||||
toggle,
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
tooltip: string;
|
|
||||||
isTogglable: boolean;
|
|
||||||
settingEnabled: boolean;
|
|
||||||
toggle: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Label
|
|
||||||
htmlFor={id}
|
|
||||||
className={!isTogglable ? "text-muted-foreground/50" : ""}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Info
|
|
||||||
className={`h-4 w-4 cursor-help ${!isTogglable ? "text-muted-foreground/50" : "text-muted-foreground"}`}
|
|
||||||
/>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" className="max-w-72">
|
|
||||||
{tooltip}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id={id}
|
|
||||||
checked={isTogglable ? settingEnabled : false}
|
|
||||||
onCheckedChange={toggle}
|
|
||||||
disabled={!isTogglable}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TurboEditsSelector({
|
|
||||||
isTogglable,
|
|
||||||
settings,
|
|
||||||
onValueChange,
|
|
||||||
}: {
|
|
||||||
isTogglable: boolean;
|
|
||||||
settings: UserSettings | null;
|
|
||||||
onValueChange: (value: "off" | "v1" | "v2") => void;
|
|
||||||
}) {
|
|
||||||
// Determine current value based on settings
|
|
||||||
const getCurrentValue = (): "off" | "v1" | "v2" => {
|
|
||||||
if (!settings?.enableProLazyEditsMode) {
|
|
||||||
return "off";
|
|
||||||
}
|
|
||||||
if (settings?.proLazyEditsMode === "v1") {
|
|
||||||
return "v1";
|
|
||||||
}
|
|
||||||
if (settings?.proLazyEditsMode === "v2") {
|
|
||||||
return "v2";
|
|
||||||
}
|
|
||||||
// Keep in sync with getModelClient in get_model_client.ts
|
|
||||||
// If enabled but no option set (undefined/falsey), it's v1
|
|
||||||
return "v1";
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentValue = getCurrentValue();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Label className={!isTogglable ? "text-muted-foreground/50" : ""}>
|
|
||||||
Turbo Edits
|
|
||||||
</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Info
|
|
||||||
className={`h-4 w-4 cursor-help ${!isTogglable ? "text-muted-foreground/50" : "text-muted-foreground"}`}
|
|
||||||
/>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" className="max-w-72">
|
|
||||||
Edits files efficiently without full rewrites.
|
|
||||||
<br />
|
|
||||||
<ul className="list-disc ml-4">
|
|
||||||
<li>
|
|
||||||
<b>Classic:</b> Uses a smaller model to complete edits.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<b>Search & replace:</b> Find and replaces specific text blocks.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="inline-flex rounded-md border border-input"
|
|
||||||
data-testid="turbo-edits-selector"
|
|
||||||
>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant={currentValue === "off" ? "default" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onValueChange("off")}
|
|
||||||
disabled={!isTogglable}
|
|
||||||
className="rounded-r-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
|
|
||||||
>
|
|
||||||
Off
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Disable Turbo Edits</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant={currentValue === "v1" ? "default" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onValueChange("v1")}
|
|
||||||
disabled={!isTogglable}
|
|
||||||
className="rounded-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
|
|
||||||
>
|
|
||||||
Classic
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
Uses a smaller model to complete edits
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant={currentValue === "v2" ? "default" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onValueChange("v2")}
|
|
||||||
disabled={!isTogglable}
|
|
||||||
className="rounded-l-none h-8 px-3 text-xs flex-shrink-0"
|
|
||||||
>
|
|
||||||
Search & replace
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
Find and replaces specific text blocks
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SmartContextSelector({
|
|
||||||
isTogglable,
|
|
||||||
settings,
|
|
||||||
onValueChange,
|
|
||||||
}: {
|
|
||||||
isTogglable: boolean;
|
|
||||||
settings: UserSettings | null;
|
|
||||||
onValueChange: (value: "off" | "balanced" | "deep") => void;
|
|
||||||
}) {
|
|
||||||
// Determine current value based on settings
|
|
||||||
const getCurrentValue = (): "off" | "conservative" | "balanced" | "deep" => {
|
|
||||||
if (!settings?.enableProSmartFilesContextMode) {
|
|
||||||
return "off";
|
|
||||||
}
|
|
||||||
if (settings?.proSmartContextOption === "deep") {
|
|
||||||
return "deep";
|
|
||||||
}
|
|
||||||
if (settings?.proSmartContextOption === "balanced") {
|
|
||||||
return "balanced";
|
|
||||||
}
|
|
||||||
// Keep logic in sync with isDeepContextEnabled in chat_stream_handlers.ts
|
|
||||||
return "deep";
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentValue = getCurrentValue();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Label className={!isTogglable ? "text-muted-foreground/50" : ""}>
|
|
||||||
Smart Context
|
|
||||||
</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Info
|
|
||||||
className={`h-4 w-4 cursor-help ${!isTogglable ? "text-muted-foreground/50" : "text-muted-foreground"}`}
|
|
||||||
/>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" className="max-w-72">
|
|
||||||
Selects the most relevant files as context to save credits working
|
|
||||||
on large codebases.
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="inline-flex rounded-md border border-input"
|
|
||||||
data-testid="smart-context-selector"
|
|
||||||
>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant={currentValue === "off" ? "default" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onValueChange("off")}
|
|
||||||
disabled={!isTogglable}
|
|
||||||
className="rounded-r-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
|
|
||||||
>
|
|
||||||
Off
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Disable Smart Context</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant={currentValue === "balanced" ? "default" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onValueChange("balanced")}
|
|
||||||
disabled={!isTogglable}
|
|
||||||
className="rounded-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
|
|
||||||
>
|
|
||||||
Balanced
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
Selects most relevant files with balanced context size
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant={currentValue === "deep" ? "default" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onValueChange("deep")}
|
|
||||||
disabled={!isTogglable}
|
|
||||||
className="rounded-l-none h-8 px-3 text-xs flex-shrink-0"
|
|
||||||
>
|
|
||||||
Deep
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<b>Experimental:</b> Keeps full conversation history for maximum
|
|
||||||
context and cache-optimized to control costs
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
import {
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
|
||||||
import { providerSettingsRoute } from "@/routes/settings/providers/$provider";
|
|
||||||
import type { LanguageModelProvider } from "@/ipc/ipc_types";
|
|
||||||
|
|
||||||
import { useLanguageModelProviders } from "@/hooks/useLanguageModelProviders";
|
|
||||||
import { useCustomLanguageModelProvider } from "@/hooks/useCustomLanguageModelProvider";
|
|
||||||
import { GiftIcon, PlusIcon, Trash2, Edit } from "lucide-react";
|
|
||||||
import { Skeleton } from "./ui/skeleton";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
|
||||||
import { AlertTriangle } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
|
|
||||||
import { CreateCustomProviderDialog } from "./CreateCustomProviderDialog";
|
|
||||||
|
|
||||||
export function ProviderSettingsGrid() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
||||||
const [editingProvider, setEditingProvider] =
|
|
||||||
useState<LanguageModelProvider | null>(null);
|
|
||||||
const [providerToDelete, setProviderToDelete] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: providers,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
isProviderSetup,
|
|
||||||
refetch,
|
|
||||||
} = useLanguageModelProviders();
|
|
||||||
|
|
||||||
const { deleteProvider, isDeleting } = useCustomLanguageModelProvider();
|
|
||||||
|
|
||||||
const handleProviderClick = (providerId: string) => {
|
|
||||||
navigate({
|
|
||||||
to: providerSettingsRoute.id,
|
|
||||||
params: { provider: providerId },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteProvider = async () => {
|
|
||||||
if (providerToDelete) {
|
|
||||||
await deleteProvider(providerToDelete);
|
|
||||||
setProviderToDelete(null);
|
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditProvider = (provider: LanguageModelProvider) => {
|
|
||||||
setEditingProvider(provider);
|
|
||||||
setIsDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<h2 className="text-lg font-medium mb-6">AI Providers</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{[1, 2, 3, 4, 5].map((i) => (
|
|
||||||
<Card key={i} className="border-border">
|
|
||||||
<CardHeader className="p-4">
|
|
||||||
<Skeleton className="h-6 w-3/4 mb-2" />
|
|
||||||
<Skeleton className="h-4 w-1/2" />
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<h2 className="text-lg font-medium mb-6">AI Providers</h2>
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTriangle className="h-4 w-4" />
|
|
||||||
<AlertTitle>Error</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Failed to load AI providers: {error.message}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<h2 className="text-lg font-medium mb-6">AI Providers</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{providers
|
|
||||||
?.filter((p) => p.type !== "local")
|
|
||||||
.map((provider: LanguageModelProvider) => {
|
|
||||||
const isCustom = provider.type === "custom";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={provider.id}
|
|
||||||
className="relative transition-all hover:shadow-md border-border"
|
|
||||||
>
|
|
||||||
<CardHeader
|
|
||||||
className="p-4 cursor-pointer"
|
|
||||||
onClick={() => handleProviderClick(provider.id)}
|
|
||||||
>
|
|
||||||
{isCustom && (
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-end"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
data-testid="edit-custom-provider"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-8 p-0 hover:bg-muted rounded-md"
|
|
||||||
onClick={() => handleEditProvider(provider)}
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Edit Provider</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
data-testid="delete-custom-provider"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive hover:bg-destructive/10 rounded-md"
|
|
||||||
onClick={() => setProviderToDelete(provider.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Delete Provider</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<CardTitle className="text-lg font-medium mb-2">
|
|
||||||
{provider.name}
|
|
||||||
{isProviderSetup(provider.id) ? (
|
|
||||||
<span className="ml-3 text-sm font-medium text-green-500 bg-green-50 dark:bg-green-900/30 border border-green-500/50 dark:border-green-500/50 px-2 py-1 rounded-full">
|
|
||||||
Ready
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-gray-500 bg-gray-50 dark:bg-gray-900 dark:text-gray-300 px-2 py-1 rounded-full">
|
|
||||||
Needs Setup
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{provider.hasFreeTier && (
|
|
||||||
<span className="text-blue-600 mt-2 dark:text-blue-400 text-sm font-medium bg-blue-100 dark:bg-blue-900/30 px-2 py-1 rounded-full inline-flex items-center">
|
|
||||||
<GiftIcon className="w-4 h-4 mr-1" />
|
|
||||||
Free tier available
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Add custom provider button */}
|
|
||||||
<Card
|
|
||||||
className="cursor-pointer transition-all hover:shadow-md border-border border-dashed hover:border-primary/70"
|
|
||||||
onClick={() => setIsDialogOpen(true)}
|
|
||||||
>
|
|
||||||
<CardHeader className="p-4 flex flex-col items-center justify-center h-full">
|
|
||||||
<PlusIcon className="h-8 w-8 text-muted-foreground mb-2" />
|
|
||||||
<CardTitle className="text-lg font-medium text-center">
|
|
||||||
Add custom provider
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-center">
|
|
||||||
Connect to a custom LLM API endpoint
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CreateCustomProviderDialog
|
|
||||||
isOpen={isDialogOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setIsDialogOpen(false);
|
|
||||||
setEditingProvider(null);
|
|
||||||
}}
|
|
||||||
onSuccess={() => {
|
|
||||||
setIsDialogOpen(false);
|
|
||||||
refetch();
|
|
||||||
setEditingProvider(null);
|
|
||||||
}}
|
|
||||||
editingProvider={editingProvider}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AlertDialog
|
|
||||||
open={!!providerToDelete}
|
|
||||||
onOpenChange={(open) => !open && setProviderToDelete(null)}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete Custom Provider</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This will permanently delete this custom provider and all its
|
|
||||||
associated models. This action cannot be undone.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={handleDeleteProvider}
|
|
||||||
disabled={isDeleting}
|
|
||||||
>
|
|
||||||
{isDeleting ? "Deleting..." : "Delete Provider"}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
import type { ReleaseChannel } from "@/lib/schemas";
|
|
||||||
|
|
||||||
export function ReleaseChannelSelector() {
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
|
|
||||||
if (!settings) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleReleaseChannelChange = (value: ReleaseChannel) => {
|
|
||||||
updateSettings({ releaseChannel: value });
|
|
||||||
if (value === "stable") {
|
|
||||||
toast("Using Stable release channel", {
|
|
||||||
description:
|
|
||||||
"You'll stay on your current version until a newer stable release is available, or you can manually downgrade now.",
|
|
||||||
action: {
|
|
||||||
label: "Download Stable",
|
|
||||||
onClick: () => {
|
|
||||||
IpcClient.getInstance().openExternalUrl("https://dyad.sh/download");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast("Using Beta release channel", {
|
|
||||||
description:
|
|
||||||
"You will need to restart Dyad for your settings to take effect.",
|
|
||||||
action: {
|
|
||||||
label: "Restart Dyad",
|
|
||||||
onClick: () => {
|
|
||||||
IpcClient.getInstance().restartDyad();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<label
|
|
||||||
htmlFor="release-channel"
|
|
||||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Release Channel
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={settings.releaseChannel}
|
|
||||||
onValueChange={handleReleaseChannelChange}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-32" id="release-channel">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="stable">Stable</SelectItem>
|
|
||||||
<SelectItem value="beta">Beta</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
<p>Stable is recommended for most users. </p>
|
|
||||||
<p>Beta receives more frequent updates but may have more bugs.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import { showError } from "@/lib/toast";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
|
|
||||||
export function RuntimeModeSelector() {
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
|
|
||||||
if (!settings) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDockerMode = settings?.runtimeMode2 === "docker";
|
|
||||||
|
|
||||||
const handleRuntimeModeChange = async (value: "host" | "docker") => {
|
|
||||||
try {
|
|
||||||
await updateSettings({ runtimeMode2: value });
|
|
||||||
} catch (error: any) {
|
|
||||||
showError(`Failed to update runtime mode: ${error.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Label className="text-sm font-medium" htmlFor="runtime-mode">
|
|
||||||
Runtime Mode
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={settings.runtimeMode2 ?? "host"}
|
|
||||||
onValueChange={handleRuntimeModeChange}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-48" id="runtime-mode">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="host">Local (default)</SelectItem>
|
|
||||||
<SelectItem value="docker">Docker (experimental)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
Choose whether to run apps directly on the local machine or in Docker
|
|
||||||
containers
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isDockerMode && (
|
|
||||||
<div className="text-sm text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 p-2 rounded">
|
|
||||||
⚠️ Docker mode is <b>experimental</b> and requires{" "}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="underline font-medium cursor-pointer"
|
|
||||||
onClick={() =>
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
"https://www.docker.com/products/docker-desktop/",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Docker Desktop
|
|
||||||
</button>{" "}
|
|
||||||
to be installed and running
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { DialogTitle } from "@radix-ui/react-dialog";
|
|
||||||
import { Dialog, DialogContent, DialogHeader } from "./ui/dialog";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import { BugIcon } from "lucide-react";
|
|
||||||
|
|
||||||
interface ScreenshotSuccessDialogProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
handleReportBug: () => Promise<void>;
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ScreenshotSuccessDialog({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
handleReportBug,
|
|
||||||
isLoading,
|
|
||||||
}: ScreenshotSuccessDialogProps) {
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
await handleReportBug();
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
Screenshot captured to clipboard! Please paste in GitHub issue.
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
className="w-full py-6 border-primary/50 shadow-sm shadow-primary/10 transition-all hover:shadow-md hover:shadow-primary/15"
|
|
||||||
>
|
|
||||||
<BugIcon className="mr-2 h-5 w-5" />{" "}
|
|
||||||
{isLoading ? "Preparing Report..." : "Create GitHub issue"}
|
|
||||||
</Button>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useScrollAndNavigateTo } from "@/hooks/useScrollAndNavigateTo";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { activeSettingsSectionAtom } from "@/atoms/viewAtoms";
|
|
||||||
|
|
||||||
const SETTINGS_SECTIONS = [
|
|
||||||
{ id: "general-settings", label: "General" },
|
|
||||||
{ id: "workflow-settings", label: "Workflow" },
|
|
||||||
{ id: "ai-settings", label: "AI" },
|
|
||||||
{ id: "provider-settings", label: "Model Providers" },
|
|
||||||
{ id: "telemetry", label: "Telemetry" },
|
|
||||||
{ id: "integrations", label: "Integrations" },
|
|
||||||
{ id: "tools-mcp", label: "Tools (MCP)" },
|
|
||||||
{ id: "experiments", label: "Experiments" },
|
|
||||||
{ id: "danger-zone", label: "Danger Zone" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function SettingsList({ show }: { show: boolean }) {
|
|
||||||
const [activeSection, setActiveSection] = useAtom(activeSettingsSectionAtom);
|
|
||||||
const scrollAndNavigateTo = useScrollAndNavigateTo("/settings", {
|
|
||||||
behavior: "smooth",
|
|
||||||
block: "start",
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
setActiveSection(entry.target.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ rootMargin: "-20% 0px -80% 0px", threshold: 0 },
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const section of SETTINGS_SECTIONS) {
|
|
||||||
const el = document.getElementById(section.id);
|
|
||||||
if (el) {
|
|
||||||
observer.observe(el);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
observer.disconnect();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!show) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleScrollAndNavigateTo = scrollAndNavigateTo;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="flex-shrink-0 p-4">
|
|
||||||
<h2 className="text-lg font-semibold tracking-tight">Settings</h2>
|
|
||||||
</div>
|
|
||||||
<ScrollArea className="flex-grow">
|
|
||||||
<div className="space-y-1 p-4 pt-0">
|
|
||||||
{SETTINGS_SECTIONS.map((section) => (
|
|
||||||
<button
|
|
||||||
key={section.id}
|
|
||||||
onClick={() => handleScrollAndNavigateTo(section.id)}
|
|
||||||
className={cn(
|
|
||||||
"w-full text-left px-3 py-2 rounded-md text-sm transition-colors",
|
|
||||||
activeSection === section.id
|
|
||||||
? "bg-sidebar-accent text-sidebar-accent-foreground font-semibold"
|
|
||||||
: "hover:bg-sidebar-accent",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{section.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,488 +0,0 @@
|
|||||||
import { useNavigate } from "@tanstack/react-router";
|
|
||||||
import {
|
|
||||||
ChevronRight,
|
|
||||||
GiftIcon,
|
|
||||||
Sparkles,
|
|
||||||
CheckCircle,
|
|
||||||
AlertCircle,
|
|
||||||
XCircle,
|
|
||||||
Loader2,
|
|
||||||
Settings,
|
|
||||||
Folder,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { providerSettingsRoute } from "@/routes/settings/providers/$provider";
|
|
||||||
|
|
||||||
import SetupProviderCard from "@/components/SetupProviderCard";
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from "@/components/ui/accordion";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { NodeSystemInfo } from "@/ipc/ipc_types";
|
|
||||||
import { usePostHog } from "posthog-js/react";
|
|
||||||
import { useLanguageModelProviders } from "@/hooks/useLanguageModelProviders";
|
|
||||||
import { useScrollAndNavigateTo } from "@/hooks/useScrollAndNavigateTo";
|
|
||||||
// @ts-ignore
|
|
||||||
import logo from "../../assets/logo.svg";
|
|
||||||
import { OnboardingBanner } from "./home/OnboardingBanner";
|
|
||||||
import { showError } from "@/lib/toast";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
|
|
||||||
type NodeInstallStep =
|
|
||||||
| "install"
|
|
||||||
| "waiting-for-continue"
|
|
||||||
| "continue-processing"
|
|
||||||
| "finished-checking";
|
|
||||||
|
|
||||||
export function SetupBanner() {
|
|
||||||
const posthog = usePostHog();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [isOnboardingVisible, setIsOnboardingVisible] = useState(true);
|
|
||||||
const { isAnyProviderSetup, isLoading: loading } =
|
|
||||||
useLanguageModelProviders();
|
|
||||||
const [nodeSystemInfo, setNodeSystemInfo] = useState<NodeSystemInfo | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [nodeCheckError, setNodeCheckError] = useState<boolean>(false);
|
|
||||||
const [nodeInstallStep, setNodeInstallStep] =
|
|
||||||
useState<NodeInstallStep>("install");
|
|
||||||
const checkNode = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setNodeCheckError(false);
|
|
||||||
const status = await IpcClient.getInstance().getNodejsStatus();
|
|
||||||
setNodeSystemInfo(status);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to check Node.js status:", error);
|
|
||||||
setNodeSystemInfo(null);
|
|
||||||
setNodeCheckError(true);
|
|
||||||
}
|
|
||||||
}, [setNodeSystemInfo, setNodeCheckError]);
|
|
||||||
const [showManualConfig, setShowManualConfig] = useState(false);
|
|
||||||
const [isSelectingPath, setIsSelectingPath] = useState(false);
|
|
||||||
const { updateSettings } = useSettings();
|
|
||||||
|
|
||||||
// Add handler for manual path selection
|
|
||||||
const handleManualNodeConfig = useCallback(async () => {
|
|
||||||
setIsSelectingPath(true);
|
|
||||||
try {
|
|
||||||
const result = await IpcClient.getInstance().selectNodeFolder();
|
|
||||||
if (result.path) {
|
|
||||||
await updateSettings({ customNodePath: result.path });
|
|
||||||
await IpcClient.getInstance().reloadEnvPath();
|
|
||||||
await checkNode();
|
|
||||||
setNodeInstallStep("finished-checking");
|
|
||||||
setShowManualConfig(false);
|
|
||||||
} else if (result.path === null && result.canceled === false) {
|
|
||||||
showError(
|
|
||||||
`Could not find Node.js at the path "${result.selectedPath}"`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showError("Error setting Node.js path:" + error);
|
|
||||||
} finally {
|
|
||||||
setIsSelectingPath(false);
|
|
||||||
}
|
|
||||||
}, [checkNode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
checkNode();
|
|
||||||
}, [checkNode]);
|
|
||||||
|
|
||||||
const settingsScrollAndNavigateTo = useScrollAndNavigateTo("/settings", {
|
|
||||||
behavior: "smooth",
|
|
||||||
block: "start",
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleGoogleSetupClick = () => {
|
|
||||||
posthog.capture("setup-flow:ai-provider-setup:google:click");
|
|
||||||
navigate({
|
|
||||||
to: providerSettingsRoute.id,
|
|
||||||
params: { provider: "google" },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenRouterSetupClick = () => {
|
|
||||||
posthog.capture("setup-flow:ai-provider-setup:openrouter:click");
|
|
||||||
navigate({
|
|
||||||
to: providerSettingsRoute.id,
|
|
||||||
params: { provider: "openrouter" },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const handleDyadProSetupClick = () => {
|
|
||||||
posthog.capture("setup-flow:ai-provider-setup:dyad:click");
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
"https://www.dyad.sh/pro?utm_source=dyad-app&utm_medium=app&utm_campaign=setup-banner",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOtherProvidersClick = () => {
|
|
||||||
posthog.capture("setup-flow:ai-provider-setup:other:click");
|
|
||||||
settingsScrollAndNavigateTo("provider-settings");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNodeInstallClick = useCallback(async () => {
|
|
||||||
posthog.capture("setup-flow:start-node-install-click");
|
|
||||||
setNodeInstallStep("waiting-for-continue");
|
|
||||||
IpcClient.getInstance().openExternalUrl(nodeSystemInfo!.nodeDownloadUrl);
|
|
||||||
}, [nodeSystemInfo, setNodeInstallStep]);
|
|
||||||
|
|
||||||
const finishNodeInstall = useCallback(async () => {
|
|
||||||
posthog.capture("setup-flow:continue-node-install-click");
|
|
||||||
setNodeInstallStep("continue-processing");
|
|
||||||
await IpcClient.getInstance().reloadEnvPath();
|
|
||||||
await checkNode();
|
|
||||||
setNodeInstallStep("finished-checking");
|
|
||||||
}, [checkNode, setNodeInstallStep]);
|
|
||||||
|
|
||||||
// We only check for node version because pnpm is not required for the app to run.
|
|
||||||
const isNodeSetupComplete = Boolean(nodeSystemInfo?.nodeVersion);
|
|
||||||
|
|
||||||
const itemsNeedAction: string[] = [];
|
|
||||||
if (!isNodeSetupComplete && nodeSystemInfo) {
|
|
||||||
itemsNeedAction.push("node-setup");
|
|
||||||
}
|
|
||||||
if (!isAnyProviderSetup() && !loading) {
|
|
||||||
itemsNeedAction.push("ai-setup");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemsNeedAction.length === 0) {
|
|
||||||
return (
|
|
||||||
<h1 className="text-center text-5xl font-bold mb-8 bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-600 dark:from-gray-100 dark:to-gray-400 tracking-tight">
|
|
||||||
Build your dream app
|
|
||||||
</h1>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const bannerClasses = cn(
|
|
||||||
"w-full mb-6 border rounded-xl shadow-sm overflow-hidden",
|
|
||||||
"border-zinc-200 dark:border-zinc-700",
|
|
||||||
);
|
|
||||||
|
|
||||||
const getStatusIcon = (isComplete: boolean, hasError: boolean = false) => {
|
|
||||||
if (hasError) {
|
|
||||||
return <XCircle className="w-5 h-5 text-red-500" />;
|
|
||||||
}
|
|
||||||
return isComplete ? (
|
|
||||||
<CheckCircle className="w-5 h-5 text-green-600 dark:text-green-500" />
|
|
||||||
) : (
|
|
||||||
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-500" />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p className="text-xl font-medium text-zinc-700 dark:text-zinc-300 p-4">
|
|
||||||
Setup Dyad
|
|
||||||
</p>
|
|
||||||
<OnboardingBanner
|
|
||||||
isVisible={isOnboardingVisible}
|
|
||||||
setIsVisible={setIsOnboardingVisible}
|
|
||||||
/>
|
|
||||||
<div className={bannerClasses}>
|
|
||||||
<Accordion
|
|
||||||
type="multiple"
|
|
||||||
className="w-full"
|
|
||||||
defaultValue={itemsNeedAction}
|
|
||||||
>
|
|
||||||
<AccordionItem
|
|
||||||
value="node-setup"
|
|
||||||
className={cn(
|
|
||||||
nodeCheckError
|
|
||||||
? "bg-red-50 dark:bg-red-900/30"
|
|
||||||
: isNodeSetupComplete
|
|
||||||
? "bg-green-50 dark:bg-green-900/30"
|
|
||||||
: "bg-yellow-50 dark:bg-yellow-900/30",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<AccordionTrigger className="px-4 py-3 transition-colors w-full hover:no-underline">
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{getStatusIcon(isNodeSetupComplete, nodeCheckError)}
|
|
||||||
<span className="font-medium text-sm">
|
|
||||||
1. Install Node.js (App Runtime)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="px-4 pt-2 pb-4 bg-white dark:bg-zinc-900 border-t border-inherit">
|
|
||||||
{nodeCheckError && (
|
|
||||||
<p className="text-sm text-red-600 dark:text-red-400">
|
|
||||||
Error checking Node.js status. Try installing Node.js.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{isNodeSetupComplete ? (
|
|
||||||
<p className="text-sm">
|
|
||||||
Node.js ({nodeSystemInfo!.nodeVersion}) installed.{" "}
|
|
||||||
{nodeSystemInfo!.pnpmVersion && (
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
{" "}
|
|
||||||
(optional) pnpm ({nodeSystemInfo!.pnpmVersion}) installed.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm">
|
|
||||||
<p>Node.js is required to run apps locally.</p>
|
|
||||||
{nodeInstallStep === "waiting-for-continue" && (
|
|
||||||
<p className="mt-1">
|
|
||||||
After you have installed Node.js, click "Continue". If the
|
|
||||||
installer didn't work, try{" "}
|
|
||||||
<a
|
|
||||||
className="text-blue-500 dark:text-blue-400 hover:underline"
|
|
||||||
onClick={() => {
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
"https://nodejs.org/en/download",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
more download options
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<NodeInstallButton
|
|
||||||
nodeInstallStep={nodeInstallStep}
|
|
||||||
handleNodeInstallClick={handleNodeInstallClick}
|
|
||||||
finishNodeInstall={finishNodeInstall}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowManualConfig(!showManualConfig)}
|
|
||||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
|
||||||
>
|
|
||||||
Node.js already installed? Configure path manually →
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showManualConfig && (
|
|
||||||
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
|
||||||
<Button
|
|
||||||
onClick={handleManualNodeConfig}
|
|
||||||
disabled={isSelectingPath}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{isSelectingPath ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Selecting...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Folder className="mr-2 h-4 w-4" />
|
|
||||||
Browse for Node.js folder
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<NodeJsHelpCallout />
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
|
|
||||||
<AccordionItem
|
|
||||||
value="ai-setup"
|
|
||||||
className={cn(
|
|
||||||
isAnyProviderSetup()
|
|
||||||
? "bg-green-50 dark:bg-green-900/30"
|
|
||||||
: "bg-yellow-50 dark:bg-yellow-900/30",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<AccordionTrigger
|
|
||||||
className={cn(
|
|
||||||
"px-4 py-3 transition-colors w-full hover:no-underline",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{getStatusIcon(isAnyProviderSetup())}
|
|
||||||
<span className="font-medium text-sm">
|
|
||||||
2. Setup AI Access
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="px-4 pt-2 pb-4 bg-white dark:bg-zinc-900 border-t border-inherit">
|
|
||||||
<p className="text-[15px] mb-3">
|
|
||||||
Not sure what to do? Watch the Get Started video above ☝️
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<SetupProviderCard
|
|
||||||
className="flex-1"
|
|
||||||
variant="google"
|
|
||||||
onClick={handleGoogleSetupClick}
|
|
||||||
tabIndex={isNodeSetupComplete ? 0 : -1}
|
|
||||||
leadingIcon={
|
|
||||||
<Sparkles className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
|
||||||
}
|
|
||||||
title="Setup Google Gemini API Key"
|
|
||||||
chip={<>Free</>}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SetupProviderCard
|
|
||||||
className="flex-1"
|
|
||||||
variant="openrouter"
|
|
||||||
onClick={handleOpenRouterSetupClick}
|
|
||||||
tabIndex={isNodeSetupComplete ? 0 : -1}
|
|
||||||
leadingIcon={
|
|
||||||
<Sparkles className="w-4 h-4 text-teal-600 dark:text-teal-400" />
|
|
||||||
}
|
|
||||||
title="Setup OpenRouter API Key"
|
|
||||||
chip={<>Free</>}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SetupProviderCard
|
|
||||||
className="mt-2"
|
|
||||||
variant="dyad"
|
|
||||||
onClick={handleDyadProSetupClick}
|
|
||||||
tabIndex={isNodeSetupComplete ? 0 : -1}
|
|
||||||
leadingIcon={
|
|
||||||
<img src={logo} alt="Dyad Logo" className="w-6 h-6 mr-0.5" />
|
|
||||||
}
|
|
||||||
title="Setup Dyad Pro"
|
|
||||||
subtitle="Access all AI models with one plan"
|
|
||||||
chip={<>Recommended</>}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="mt-2 p-3 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800/70 transition-colors"
|
|
||||||
onClick={handleOtherProvidersClick}
|
|
||||||
role="button"
|
|
||||||
tabIndex={isNodeSetupComplete ? 0 : -1}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="bg-gray-100 dark:bg-gray-700 p-1.5 rounded-full">
|
|
||||||
<Settings className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-[15px] text-gray-800 dark:text-gray-300">
|
|
||||||
Setup other AI providers
|
|
||||||
</h4>
|
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
OpenAI, Anthropic and more
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronRight className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NodeJsHelpCallout() {
|
|
||||||
return (
|
|
||||||
<div className="mt-3 p-3 bg-(--background-lighter) border rounded-lg text-sm">
|
|
||||||
<p>
|
|
||||||
If you run into issues, read our{" "}
|
|
||||||
<a
|
|
||||||
onClick={() => {
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
"https://www.dyad.sh/docs/help/nodejs",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="text-blue-600 dark:text-blue-400 hover:underline font-medium"
|
|
||||||
>
|
|
||||||
Node.js troubleshooting guide
|
|
||||||
</a>
|
|
||||||
.{" "}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2">
|
|
||||||
Still stuck? Click the <b>Help</b> button in the bottom-left corner and
|
|
||||||
then <b>Report a Bug</b>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NodeInstallButton({
|
|
||||||
nodeInstallStep,
|
|
||||||
handleNodeInstallClick,
|
|
||||||
finishNodeInstall,
|
|
||||||
}: {
|
|
||||||
nodeInstallStep: NodeInstallStep;
|
|
||||||
handleNodeInstallClick: () => void;
|
|
||||||
finishNodeInstall: () => void;
|
|
||||||
}) {
|
|
||||||
switch (nodeInstallStep) {
|
|
||||||
case "install":
|
|
||||||
return (
|
|
||||||
<Button className="mt-3" onClick={handleNodeInstallClick}>
|
|
||||||
Install Node.js Runtime
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
case "continue-processing":
|
|
||||||
return (
|
|
||||||
<Button className="mt-3" onClick={finishNodeInstall} disabled>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
Checking Node.js setup...
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
case "waiting-for-continue":
|
|
||||||
return (
|
|
||||||
<Button className="mt-3" onClick={finishNodeInstall}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
Continue | I installed Node.js
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
case "finished-checking":
|
|
||||||
return (
|
|
||||||
<div className="mt-3 text-sm text-red-600 dark:text-red-400">
|
|
||||||
Node.js not detected. Closing and re-opening Dyad usually fixes this.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
const _exhaustiveCheck: never = nodeInstallStep;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const OpenRouterSetupBanner = ({
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
className?: string;
|
|
||||||
}) => {
|
|
||||||
const posthog = usePostHog();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
return (
|
|
||||||
<SetupProviderCard
|
|
||||||
className={cn("mt-2", className)}
|
|
||||||
variant="openrouter"
|
|
||||||
onClick={() => {
|
|
||||||
posthog.capture("setup-flow:ai-provider-setup:openrouter:click");
|
|
||||||
navigate({
|
|
||||||
to: providerSettingsRoute.id,
|
|
||||||
params: { provider: "openrouter" },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
tabIndex={0}
|
|
||||||
leadingIcon={
|
|
||||||
<Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
|
||||||
}
|
|
||||||
title="Setup OpenRouter API Key"
|
|
||||||
chip={
|
|
||||||
<>
|
|
||||||
<GiftIcon className="w-3 h-3" />
|
|
||||||
Free models available
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { ChevronRight } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { ReactNode } from "react";
|
|
||||||
|
|
||||||
type SetupProviderVariant = "google" | "openrouter" | "dyad";
|
|
||||||
|
|
||||||
export function SetupProviderCard({
|
|
||||||
variant,
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
chip,
|
|
||||||
leadingIcon,
|
|
||||||
onClick,
|
|
||||||
tabIndex = 0,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
variant: SetupProviderVariant;
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
chip?: ReactNode;
|
|
||||||
leadingIcon: ReactNode;
|
|
||||||
onClick: () => void;
|
|
||||||
tabIndex?: number;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const styles = getVariantStyles(variant);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"p-3 border rounded-lg cursor-pointer transition-colors relative",
|
|
||||||
styles.container,
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
onClick={onClick}
|
|
||||||
role="button"
|
|
||||||
tabIndex={tabIndex}
|
|
||||||
>
|
|
||||||
{chip && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute top-2 right-2 px-2 py-1 rounded-full text-xs font-semibold",
|
|
||||||
styles.subtitleColor,
|
|
||||||
"bg-white/80 dark:bg-black/20 backdrop-blur-sm",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{chip}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className={cn("p-1.5 rounded-full", styles.iconWrapper)}>
|
|
||||||
{leadingIcon}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className={cn("font-medium text-[15px]", styles.titleColor)}>
|
|
||||||
{title}
|
|
||||||
</h4>
|
|
||||||
{subtitle ? (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"text-sm flex items-center gap-1",
|
|
||||||
styles.subtitleColor,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{subtitle}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronRight className={cn("w-4 h-4", styles.chevronColor)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getVariantStyles(variant: SetupProviderVariant) {
|
|
||||||
switch (variant) {
|
|
||||||
case "google":
|
|
||||||
return {
|
|
||||||
container:
|
|
||||||
"bg-blue-50 dark:bg-blue-900/50 border-blue-200 dark:border-blue-700 hover:bg-blue-100 dark:hover:bg-blue-900/70",
|
|
||||||
iconWrapper: "bg-blue-100 dark:bg-blue-800",
|
|
||||||
titleColor: "text-blue-800 dark:text-blue-300",
|
|
||||||
subtitleColor: "text-blue-600 dark:text-blue-400",
|
|
||||||
chevronColor: "text-blue-600 dark:text-blue-400",
|
|
||||||
} as const;
|
|
||||||
case "openrouter":
|
|
||||||
return {
|
|
||||||
container:
|
|
||||||
"bg-teal-50 dark:bg-teal-900/50 border-teal-200 dark:border-teal-700 hover:bg-teal-100 dark:hover:bg-teal-900/70",
|
|
||||||
iconWrapper: "bg-teal-100 dark:bg-teal-800",
|
|
||||||
titleColor: "text-teal-800 dark:text-teal-300",
|
|
||||||
subtitleColor: "text-teal-600 dark:text-teal-400",
|
|
||||||
chevronColor: "text-teal-600 dark:text-teal-400",
|
|
||||||
} as const;
|
|
||||||
case "dyad":
|
|
||||||
return {
|
|
||||||
container:
|
|
||||||
"bg-primary/10 border-primary/50 dark:bg-violet-800/50 dark:border-violet-700 hover:bg-violet-100 dark:hover:bg-violet-900/70",
|
|
||||||
iconWrapper: "bg-primary/5 dark:bg-violet-800",
|
|
||||||
titleColor: "text-violet-800 dark:text-violet-300",
|
|
||||||
subtitleColor: "text-violet-600 dark:text-violet-400",
|
|
||||||
chevronColor: "text-violet-600 dark:text-violet-400",
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SetupProviderCard;
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import { useSupabase } from "@/hooks/useSupabase";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { useLoadApp } from "@/hooks/useLoadApp";
|
|
||||||
import { useDeepLink } from "@/contexts/DeepLinkContext";
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
import supabaseLogoLight from "../../assets/supabase/supabase-logo-wordmark--light.svg";
|
|
||||||
// @ts-ignore
|
|
||||||
import supabaseLogoDark from "../../assets/supabase/supabase-logo-wordmark--dark.svg";
|
|
||||||
// @ts-ignore
|
|
||||||
import connectSupabaseDark from "../../assets/supabase/connect-supabase-dark.svg";
|
|
||||||
// @ts-ignore
|
|
||||||
import connectSupabaseLight from "../../assets/supabase/connect-supabase-light.svg";
|
|
||||||
|
|
||||||
import { ExternalLink } from "lucide-react";
|
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
|
||||||
|
|
||||||
export function SupabaseConnector({ appId }: { appId: number }) {
|
|
||||||
const { settings, refreshSettings } = useSettings();
|
|
||||||
const { app, refreshApp } = useLoadApp(appId);
|
|
||||||
const { lastDeepLink, clearLastDeepLink } = useDeepLink();
|
|
||||||
const { isDarkMode } = useTheme();
|
|
||||||
useEffect(() => {
|
|
||||||
const handleDeepLink = async () => {
|
|
||||||
if (lastDeepLink?.type === "supabase-oauth-return") {
|
|
||||||
await refreshSettings();
|
|
||||||
await refreshApp();
|
|
||||||
clearLastDeepLink();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
handleDeepLink();
|
|
||||||
}, [lastDeepLink?.timestamp]);
|
|
||||||
const {
|
|
||||||
projects,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
loadProjects,
|
|
||||||
branches,
|
|
||||||
loadBranches,
|
|
||||||
setAppProject,
|
|
||||||
unsetAppProject,
|
|
||||||
} = useSupabase();
|
|
||||||
const currentProjectId = app?.supabaseProjectId;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Load projects when the component mounts and user is connected
|
|
||||||
if (settings?.supabase?.accessToken) {
|
|
||||||
loadProjects();
|
|
||||||
}
|
|
||||||
}, [settings?.supabase?.accessToken, loadProjects]);
|
|
||||||
|
|
||||||
const handleProjectSelect = async (projectId: string) => {
|
|
||||||
try {
|
|
||||||
await setAppProject({ projectId, appId });
|
|
||||||
toast.success("Project connected to app successfully");
|
|
||||||
await refreshApp();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Failed to connect project to app: " + error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const projectIdForBranches =
|
|
||||||
app?.supabaseParentProjectId || app?.supabaseProjectId;
|
|
||||||
useEffect(() => {
|
|
||||||
if (projectIdForBranches) {
|
|
||||||
loadBranches(projectIdForBranches);
|
|
||||||
}
|
|
||||||
}, [projectIdForBranches, loadBranches]);
|
|
||||||
|
|
||||||
const handleUnsetProject = async () => {
|
|
||||||
try {
|
|
||||||
await unsetAppProject(appId);
|
|
||||||
toast.success("Project disconnected from app successfully");
|
|
||||||
await refreshApp();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to disconnect project:", error);
|
|
||||||
toast.error("Failed to disconnect project from app");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (settings?.supabase?.accessToken) {
|
|
||||||
if (app?.supabaseProjectName) {
|
|
||||||
return (
|
|
||||||
<Card className="mt-1">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center justify-between">
|
|
||||||
Supabase Project{" "}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
`https://supabase.com/dashboard/project/${app.supabaseProjectId}`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="ml-2 px-2 py-1"
|
|
||||||
style={{ display: "inline-flex", alignItems: "center" }}
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<img
|
|
||||||
src={isDarkMode ? supabaseLogoDark : supabaseLogoLight}
|
|
||||||
alt="Supabase Logo"
|
|
||||||
style={{ height: 20, width: "auto", marginRight: 4 }}
|
|
||||||
/>
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
This app is connected to project: {app.supabaseProjectName}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="supabase-branch-select">Database Branch</Label>
|
|
||||||
<Select
|
|
||||||
value={app.supabaseProjectId || ""}
|
|
||||||
onValueChange={async (supabaseBranchProjectId) => {
|
|
||||||
try {
|
|
||||||
const branch = branches.find(
|
|
||||||
(b) => b.projectRef === supabaseBranchProjectId,
|
|
||||||
);
|
|
||||||
if (!branch) {
|
|
||||||
throw new Error("Branch not found");
|
|
||||||
}
|
|
||||||
await setAppProject({
|
|
||||||
projectId: branch.projectRef,
|
|
||||||
parentProjectId: branch.parentProjectRef,
|
|
||||||
appId,
|
|
||||||
});
|
|
||||||
toast.success("Branch selected");
|
|
||||||
await refreshApp();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Failed to set branch: " + error);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
id="supabase-branch-select"
|
|
||||||
data-testid="supabase-branch-select"
|
|
||||||
>
|
|
||||||
<SelectValue placeholder="Select a branch" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{branches.map((branch) => (
|
|
||||||
<SelectItem
|
|
||||||
key={branch.projectRef}
|
|
||||||
value={branch.projectRef}
|
|
||||||
>
|
|
||||||
{branch.name}
|
|
||||||
{branch.isDefault && " (Default)"}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button variant="destructive" onClick={handleUnsetProject}>
|
|
||||||
Disconnect Project
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Card className="mt-1">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Supabase Projects</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Select a Supabase project to connect to this app
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{loading ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Skeleton className="h-4 w-full" />
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="text-red-500">
|
|
||||||
Error loading projects: {error.message}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="mt-2"
|
|
||||||
onClick={() => loadProjects()}
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{projects.length === 0 ? (
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
No projects found in your Supabase account.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="project-select">Project</Label>
|
|
||||||
<Select
|
|
||||||
value={currentProjectId || ""}
|
|
||||||
onValueChange={handleProjectSelect}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="project-select">
|
|
||||||
<SelectValue placeholder="Select a project" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{projects.map((project) => (
|
|
||||||
<SelectItem key={project.id} value={project.id}>
|
|
||||||
{project.name || project.id}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{currentProjectId && (
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
This app is connected to project:{" "}
|
|
||||||
{projects.find((p) => p.id === currentProjectId)?.name ||
|
|
||||||
currentProjectId}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col space-y-4 p-4 border rounded-md">
|
|
||||||
<div className="flex flex-col md:flex-row items-center justify-between">
|
|
||||||
<h2 className="text-lg font-medium">Integrations</h2>
|
|
||||||
<img
|
|
||||||
onClick={async () => {
|
|
||||||
if (settings?.isTestMode) {
|
|
||||||
await IpcClient.getInstance().fakeHandleSupabaseConnect({
|
|
||||||
appId,
|
|
||||||
fakeProjectId: "fake-project-id",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await IpcClient.getInstance().openExternalUrl(
|
|
||||||
"https://supabase-oauth.dyad.sh/api/connect-supabase/login",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
src={isDarkMode ? connectSupabaseDark : connectSupabaseLight}
|
|
||||||
alt="Connect to Supabase"
|
|
||||||
className="w-full h-10 min-h-8 min-w-20 cursor-pointer"
|
|
||||||
data-testid="connect-supabase-button"
|
|
||||||
// className="h-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
// We might need a Supabase icon here, but for now, let's use a generic one or text.
|
|
||||||
// import { Supabase } from "lucide-react"; // Placeholder
|
|
||||||
import { DatabaseZap } from "lucide-react"; // Using DatabaseZap as a placeholder
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import { showSuccess, showError } from "@/lib/toast";
|
|
||||||
|
|
||||||
export function SupabaseIntegration() {
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
|
||||||
|
|
||||||
const handleDisconnectFromSupabase = async () => {
|
|
||||||
setIsDisconnecting(true);
|
|
||||||
try {
|
|
||||||
// Clear the entire supabase object in settings
|
|
||||||
const result = await updateSettings({
|
|
||||||
supabase: undefined,
|
|
||||||
// Also disable the migration setting on disconnect
|
|
||||||
enableSupabaseWriteSqlMigration: false,
|
|
||||||
});
|
|
||||||
if (result) {
|
|
||||||
showSuccess("Successfully disconnected from Supabase");
|
|
||||||
} else {
|
|
||||||
showError("Failed to disconnect from Supabase");
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
showError(
|
|
||||||
err.message || "An error occurred while disconnecting from Supabase",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsDisconnecting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMigrationSettingChange = async (enabled: boolean) => {
|
|
||||||
try {
|
|
||||||
await updateSettings({
|
|
||||||
enableSupabaseWriteSqlMigration: enabled,
|
|
||||||
});
|
|
||||||
showSuccess("Setting updated");
|
|
||||||
} catch (err: any) {
|
|
||||||
showError(err.message || "Failed to update setting");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if there's any Supabase accessToken to determine connection status
|
|
||||||
const isConnected = !!settings?.supabase?.accessToken;
|
|
||||||
|
|
||||||
if (!isConnected) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Supabase Integration
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
Your account is connected to Supabase.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleDisconnectFromSupabase}
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
disabled={isDisconnecting}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{isDisconnecting ? "Disconnecting..." : "Disconnect from Supabase"}
|
|
||||||
<DatabaseZap className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<Switch
|
|
||||||
id="supabase-migrations"
|
|
||||||
checked={!!settings?.enableSupabaseWriteSqlMigration}
|
|
||||||
onCheckedChange={handleMigrationSettingChange}
|
|
||||||
/>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label
|
|
||||||
htmlFor="supabase-migrations"
|
|
||||||
className="text-sm font-medium"
|
|
||||||
>
|
|
||||||
Write SQL migration files
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Generate SQL migration files when modifying your Supabase schema.
|
|
||||||
This helps you track database changes in version control, though
|
|
||||||
these files aren't used for chat context, which uses the live
|
|
||||||
schema.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
import React from "react";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import { atom, useAtom } from "jotai";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
|
|
||||||
const hideBannerAtom = atom(false);
|
|
||||||
|
|
||||||
export function PrivacyBanner() {
|
|
||||||
const [hideBanner, setHideBanner] = useAtom(hideBannerAtom);
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
// TODO: Implement state management for banner visibility and user choice
|
|
||||||
// TODO: Implement functionality for Accept, Reject, Ask me later buttons
|
|
||||||
// TODO: Add state to hide/show banner based on user choice
|
|
||||||
if (hideBanner) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (settings?.telemetryConsent !== "unset") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="fixed bg-(--background)/90 bottom-4 right-4 backdrop-blur-md border border-gray-200 dark:border-gray-700 p-4 rounded-lg shadow-lg z-50 max-w-md">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-base font-semibold text-gray-800 dark:text-gray-200">
|
|
||||||
Share anonymous data?
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
Help improve Dyad with anonymous usage data.
|
|
||||||
<em className="block italic mt-0.5">
|
|
||||||
Note: this does not log your code or messages.
|
|
||||||
</em>
|
|
||||||
<a
|
|
||||||
onClick={() => {
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
"https://dyad.sh/docs/policies/privacy-policy",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="cursor-pointer text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
|
||||||
>
|
|
||||||
Learn more
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 justify-end">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onClick={() => {
|
|
||||||
updateSettings({ telemetryConsent: "opted_in" });
|
|
||||||
}}
|
|
||||||
data-testid="telemetry-accept-button"
|
|
||||||
>
|
|
||||||
Accept
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
updateSettings({ telemetryConsent: "opted_out" });
|
|
||||||
}}
|
|
||||||
data-testid="telemetry-reject-button"
|
|
||||||
>
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setHideBanner(true)}
|
|
||||||
data-testid="telemetry-later-button"
|
|
||||||
>
|
|
||||||
Later
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
|
|
||||||
export function TelemetrySwitch() {
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
return (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Switch
|
|
||||||
id="telemetry-switch"
|
|
||||||
checked={settings?.telemetryConsent === "opted_in"}
|
|
||||||
onCheckedChange={() => {
|
|
||||||
updateSettings({
|
|
||||||
telemetryConsent:
|
|
||||||
settings?.telemetryConsent === "opted_in"
|
|
||||||
? "opted_out"
|
|
||||||
: "opted_in",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="telemetry-switch">Telemetry</Label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { ArrowLeft } from "lucide-react";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import { CommunityCodeConsentDialog } from "./CommunityCodeConsentDialog";
|
|
||||||
import type { Template } from "@/shared/templates";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { showWarning } from "@/lib/toast";
|
|
||||||
|
|
||||||
interface TemplateCardProps {
|
|
||||||
template: Template;
|
|
||||||
isSelected: boolean;
|
|
||||||
onSelect: (templateId: string) => void;
|
|
||||||
onCreateApp: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TemplateCard: React.FC<TemplateCardProps> = ({
|
|
||||||
template,
|
|
||||||
isSelected,
|
|
||||||
onSelect,
|
|
||||||
onCreateApp,
|
|
||||||
}) => {
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
const [showConsentDialog, setShowConsentDialog] = useState(false);
|
|
||||||
|
|
||||||
const handleCardClick = () => {
|
|
||||||
// If it's a community template and user hasn't accepted community code yet, show dialog
|
|
||||||
if (!template.isOfficial && !settings?.acceptedCommunityCode) {
|
|
||||||
setShowConsentDialog(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (template.requiresNeon && !settings?.neon?.accessToken) {
|
|
||||||
showWarning("Please connect your Neon account to use this template.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, proceed with selection
|
|
||||||
onSelect(template.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConsentAccept = () => {
|
|
||||||
// Update settings to accept community code
|
|
||||||
updateSettings({ acceptedCommunityCode: true });
|
|
||||||
|
|
||||||
// Select the template
|
|
||||||
onSelect(template.id);
|
|
||||||
|
|
||||||
// Close dialog
|
|
||||||
setShowConsentDialog(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConsentCancel = () => {
|
|
||||||
// Just close dialog, don't update settings or select template
|
|
||||||
setShowConsentDialog(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGithubClick = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (template.githubUrl) {
|
|
||||||
IpcClient.getInstance().openExternalUrl(template.githubUrl);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
onClick={handleCardClick}
|
|
||||||
className={`
|
|
||||||
bg-white dark:bg-gray-800 rounded-xl shadow-sm overflow-hidden
|
|
||||||
transform transition-all duration-300 ease-in-out
|
|
||||||
cursor-pointer group relative
|
|
||||||
${
|
|
||||||
isSelected
|
|
||||||
? "ring-2 ring-blue-500 dark:ring-blue-400 shadow-xl"
|
|
||||||
: "hover:shadow-lg hover:-translate-y-1"
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div className="relative">
|
|
||||||
<img
|
|
||||||
src={template.imageUrl}
|
|
||||||
alt={template.title}
|
|
||||||
className={`w-full h-52 object-cover transition-opacity duration-300 group-hover:opacity-80 ${
|
|
||||||
isSelected ? "opacity-75" : ""
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{isSelected && (
|
|
||||||
<span className="absolute top-3 right-3 bg-blue-600 text-white text-xs font-bold px-3 py-1.5 rounded-md shadow-lg">
|
|
||||||
Selected
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex justify-between items-center mb-1.5">
|
|
||||||
<h2
|
|
||||||
className={`text-lg font-semibold ${
|
|
||||||
isSelected
|
|
||||||
? "text-blue-600 dark:text-blue-400"
|
|
||||||
: "text-gray-900 dark:text-white"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{template.title}
|
|
||||||
</h2>
|
|
||||||
{template.isOfficial && !template.isExperimental && (
|
|
||||||
<span
|
|
||||||
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
|
||||||
isSelected
|
|
||||||
? "bg-blue-100 text-blue-700 dark:bg-blue-600 dark:text-blue-100"
|
|
||||||
: "bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Official
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{template.isExperimental && (
|
|
||||||
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-700 dark:text-yellow-200">
|
|
||||||
Experimental
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3 h-10 overflow-y-auto">
|
|
||||||
{template.description}
|
|
||||||
</p>
|
|
||||||
{template.githubUrl && (
|
|
||||||
<a
|
|
||||||
className={`inline-flex items-center text-sm font-medium transition-colors duration-200 ${
|
|
||||||
isSelected
|
|
||||||
? "text-blue-500 hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-200"
|
|
||||||
: "text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
|
||||||
}`}
|
|
||||||
onClick={handleGithubClick}
|
|
||||||
>
|
|
||||||
View on GitHub{" "}
|
|
||||||
<ArrowLeft className="w-4 h-4 ml-1 transform rotate-180" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onCreateApp();
|
|
||||||
}}
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
"w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold mt-2",
|
|
||||||
settings?.selectedTemplateId !== template.id && "invisible",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Create App
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CommunityCodeConsentDialog
|
|
||||||
isOpen={showConsentDialog}
|
|
||||||
onAccept={handleConsentAccept}
|
|
||||||
onCancel={handleConsentCancel}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
|
|
||||||
interface OptionInfo {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultValue = "medium";
|
|
||||||
|
|
||||||
const options: OptionInfo[] = [
|
|
||||||
{
|
|
||||||
value: "low",
|
|
||||||
label: "Low",
|
|
||||||
description:
|
|
||||||
"Minimal thinking tokens for faster responses and lower costs.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: defaultValue,
|
|
||||||
label: "Medium (default)",
|
|
||||||
description: "Balanced thinking for most conversations.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "high",
|
|
||||||
label: "High",
|
|
||||||
description:
|
|
||||||
"Extended thinking for complex problems requiring deep analysis.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const ThinkingBudgetSelector: React.FC = () => {
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
|
|
||||||
const handleValueChange = (value: string) => {
|
|
||||||
updateSettings({ thinkingBudget: value as "low" | "medium" | "high" });
|
|
||||||
};
|
|
||||||
|
|
||||||
// Determine the current value
|
|
||||||
const currentValue = settings?.thinkingBudget || defaultValue;
|
|
||||||
|
|
||||||
// Find the current option to display its description
|
|
||||||
const currentOption =
|
|
||||||
options.find((opt) => opt.value === currentValue) || options[1];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<label
|
|
||||||
htmlFor="thinking-budget"
|
|
||||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Thinking Budget
|
|
||||||
</label>
|
|
||||||
<Select value={currentValue} onValueChange={handleValueChange}>
|
|
||||||
<SelectTrigger className="w-[180px]" id="thinking-budget">
|
|
||||||
<SelectValue placeholder="Select budget" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{options.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{currentOption.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,659 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Globe } from "lucide-react";
|
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import { useLoadApp } from "@/hooks/useLoadApp";
|
|
||||||
import { useVercelDeployments } from "@/hooks/useVercelDeployments";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { App } from "@/ipc/ipc_types";
|
|
||||||
|
|
||||||
interface VercelConnectorProps {
|
|
||||||
appId: number | null;
|
|
||||||
folderName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VercelProject {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
framework: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConnectedVercelConnectorProps {
|
|
||||||
appId: number;
|
|
||||||
app: App;
|
|
||||||
refreshApp: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UnconnectedVercelConnectorProps {
|
|
||||||
appId: number | null;
|
|
||||||
folderName: string;
|
|
||||||
settings: any;
|
|
||||||
refreshSettings: () => void;
|
|
||||||
refreshApp: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConnectedVercelConnector({
|
|
||||||
appId,
|
|
||||||
app,
|
|
||||||
refreshApp,
|
|
||||||
}: ConnectedVercelConnectorProps) {
|
|
||||||
const {
|
|
||||||
deployments,
|
|
||||||
isLoading: isLoadingDeployments,
|
|
||||||
error: deploymentsError,
|
|
||||||
getDeployments: handleGetDeployments,
|
|
||||||
disconnectProject,
|
|
||||||
isDisconnecting,
|
|
||||||
disconnectError,
|
|
||||||
} = useVercelDeployments(appId);
|
|
||||||
|
|
||||||
const handleDisconnectProject = async () => {
|
|
||||||
await disconnectProject();
|
|
||||||
refreshApp();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="mt-4 w-full rounded-md"
|
|
||||||
data-testid="vercel-connected-project"
|
|
||||||
>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
Connected to Vercel Project:
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
`https://vercel.com/${app.vercelTeamSlug}/${app.vercelProjectName}`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{app.vercelProjectName}
|
|
||||||
</a>
|
|
||||||
{app.vercelDeploymentUrl && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
Live URL:{" "}
|
|
||||||
<a
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (app.vercelDeploymentUrl) {
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
app.vercelDeploymentUrl,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400 font-mono"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{app.vercelDeploymentUrl}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="mt-2 flex gap-2">
|
|
||||||
<Button onClick={handleGetDeployments} disabled={isLoadingDeployments}>
|
|
||||||
{isLoadingDeployments ? (
|
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
className="animate-spin h-5 w-5 mr-2 inline"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
style={{ display: "inline" }}
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Getting Deployments...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Refresh Deployments"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleDisconnectProject}
|
|
||||||
disabled={isDisconnecting}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{isDisconnecting ? "Disconnecting..." : "Disconnect from project"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{deploymentsError && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<p className="text-red-600">{deploymentsError}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{deployments.length > 0 && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<h4 className="font-medium mb-2">Recent Deployments:</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{deployments.map((deployment) => (
|
|
||||||
<div
|
|
||||||
key={deployment.uid}
|
|
||||||
className="bg-gray-50 dark:bg-gray-800 rounded-md p-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
|
||||||
deployment.readyState === "READY"
|
|
||||||
? "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300"
|
|
||||||
: deployment.readyState === "BUILDING"
|
|
||||||
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300"
|
|
||||||
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{deployment.readyState}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{new Date(deployment.createdAt).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
`https://${deployment.url}`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400 text-sm"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Globe className="h-4 w-4 inline mr-1" />
|
|
||||||
View
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{disconnectError && (
|
|
||||||
<p className="text-red-600 mt-2">{disconnectError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UnconnectedVercelConnector({
|
|
||||||
appId,
|
|
||||||
folderName,
|
|
||||||
settings,
|
|
||||||
refreshSettings,
|
|
||||||
refreshApp,
|
|
||||||
}: UnconnectedVercelConnectorProps) {
|
|
||||||
// --- Manual Token Entry State ---
|
|
||||||
const [accessToken, setAccessToken] = useState("");
|
|
||||||
const [isSavingToken, setIsSavingToken] = useState(false);
|
|
||||||
const [tokenError, setTokenError] = useState<string | null>(null);
|
|
||||||
const [tokenSuccess, setTokenSuccess] = useState(false);
|
|
||||||
|
|
||||||
// --- Project Setup State ---
|
|
||||||
const [projectSetupMode, setProjectSetupMode] = useState<
|
|
||||||
"create" | "existing"
|
|
||||||
>("create");
|
|
||||||
const [availableProjects, setAvailableProjects] = useState<VercelProject[]>(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
|
||||||
const [selectedProject, setSelectedProject] = useState<string>("");
|
|
||||||
|
|
||||||
// Create new project state
|
|
||||||
const [projectName, setProjectName] = useState(folderName);
|
|
||||||
const [projectAvailable, setProjectAvailable] = useState<boolean | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [projectCheckError, setProjectCheckError] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [isCheckingProject, setIsCheckingProject] = useState(false);
|
|
||||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
|
||||||
const [createProjectError, setCreateProjectError] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [createProjectSuccess, setCreateProjectSuccess] =
|
|
||||||
useState<boolean>(false);
|
|
||||||
|
|
||||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
// Load available projects when Vercel is connected
|
|
||||||
useEffect(() => {
|
|
||||||
if (settings?.vercelAccessToken && projectSetupMode === "existing") {
|
|
||||||
loadAvailableProjects();
|
|
||||||
}
|
|
||||||
}, [settings?.vercelAccessToken, projectSetupMode]);
|
|
||||||
|
|
||||||
// Cleanup debounce timer on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (debounceTimeoutRef.current) {
|
|
||||||
clearTimeout(debounceTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadAvailableProjects = async () => {
|
|
||||||
setIsLoadingProjects(true);
|
|
||||||
try {
|
|
||||||
const projects = await IpcClient.getInstance().listVercelProjects();
|
|
||||||
setAvailableProjects(projects);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load Vercel projects:", error);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingProjects(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveAccessToken = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!accessToken.trim()) return;
|
|
||||||
|
|
||||||
setIsSavingToken(true);
|
|
||||||
setTokenError(null);
|
|
||||||
setTokenSuccess(false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await IpcClient.getInstance().saveVercelAccessToken({
|
|
||||||
token: accessToken.trim(),
|
|
||||||
});
|
|
||||||
setTokenSuccess(true);
|
|
||||||
setAccessToken("");
|
|
||||||
refreshSettings();
|
|
||||||
} catch (err: any) {
|
|
||||||
setTokenError(err.message || "Failed to save access token.");
|
|
||||||
} finally {
|
|
||||||
setIsSavingToken(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkProjectAvailability = useCallback(async (name: string) => {
|
|
||||||
setProjectCheckError(null);
|
|
||||||
setProjectAvailable(null);
|
|
||||||
if (!name) return;
|
|
||||||
setIsCheckingProject(true);
|
|
||||||
try {
|
|
||||||
const result = await IpcClient.getInstance().isVercelProjectAvailable({
|
|
||||||
name,
|
|
||||||
});
|
|
||||||
setProjectAvailable(result.available);
|
|
||||||
if (!result.available) {
|
|
||||||
setProjectCheckError(result.error || "Project name is not available.");
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setProjectCheckError(
|
|
||||||
err.message || "Failed to check project availability.",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsCheckingProject(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const debouncedCheckProjectAvailability = useCallback(
|
|
||||||
(name: string) => {
|
|
||||||
if (debounceTimeoutRef.current) {
|
|
||||||
clearTimeout(debounceTimeoutRef.current);
|
|
||||||
}
|
|
||||||
debounceTimeoutRef.current = setTimeout(() => {
|
|
||||||
checkProjectAvailability(name);
|
|
||||||
}, 500);
|
|
||||||
},
|
|
||||||
[checkProjectAvailability],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSetupProject = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!appId) return;
|
|
||||||
|
|
||||||
setCreateProjectError(null);
|
|
||||||
setIsCreatingProject(true);
|
|
||||||
setCreateProjectSuccess(false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (projectSetupMode === "create") {
|
|
||||||
await IpcClient.getInstance().createVercelProject({
|
|
||||||
name: projectName,
|
|
||||||
appId,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await IpcClient.getInstance().connectToExistingVercelProject({
|
|
||||||
projectId: selectedProject,
|
|
||||||
appId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setCreateProjectSuccess(true);
|
|
||||||
setProjectCheckError(null);
|
|
||||||
refreshApp();
|
|
||||||
} catch (err: any) {
|
|
||||||
setCreateProjectError(
|
|
||||||
err.message ||
|
|
||||||
`Failed to ${projectSetupMode === "create" ? "create" : "connect to"} project.`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsCreatingProject(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!settings?.vercelAccessToken) {
|
|
||||||
return (
|
|
||||||
<div className="mt-1 w-full" data-testid="vercel-unconnected-project">
|
|
||||||
<div className="w-ful">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<h3 className="font-medium">Connect to Vercel</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md p-3">
|
|
||||||
<p className="text-sm text-blue-800 dark:text-blue-200 mb-2">
|
|
||||||
To connect your app to Vercel, you'll need to create an access
|
|
||||||
token:
|
|
||||||
</p>
|
|
||||||
<ol className="list-decimal list-inside text-sm text-blue-700 dark:text-blue-300 space-y-1">
|
|
||||||
<li>If you don't have a Vercel account, sign up first</li>
|
|
||||||
<li>Go to Vercel settings to create a token</li>
|
|
||||||
<li>Copy the token and paste it below</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className="flex gap-2 mt-3">
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
"https://vercel.com/signup",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Sign Up for Vercel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
IpcClient.getInstance().openExternalUrl(
|
|
||||||
"https://vercel.com/account/settings/tokens",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white"
|
|
||||||
>
|
|
||||||
Open Vercel Settings
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSaveAccessToken} className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<Label className="block text-sm font-medium mb-1">
|
|
||||||
Vercel Access Token
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
placeholder="Enter your Vercel access token"
|
|
||||||
value={accessToken}
|
|
||||||
onChange={(e) => setAccessToken(e.target.value)}
|
|
||||||
disabled={isSavingToken}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={!accessToken.trim() || isSavingToken}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{isSavingToken ? (
|
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
className="animate-spin h-4 w-4 mr-2"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Saving Token...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Save Access Token"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{tokenError && (
|
|
||||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
|
|
||||||
<p className="text-sm text-red-800 dark:text-red-200">
|
|
||||||
{tokenError}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tokenSuccess && (
|
|
||||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md p-3">
|
|
||||||
<p className="text-sm text-green-800 dark:text-green-200">
|
|
||||||
Successfully connected to Vercel! You can now set up your
|
|
||||||
project below.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-4 w-full rounded-md" data-testid="vercel-setup-project">
|
|
||||||
{/* Collapsible Header */}
|
|
||||||
<div className="font-medium mb-2">Set up your Vercel project</div>
|
|
||||||
|
|
||||||
{/* Collapsible Content */}
|
|
||||||
<div
|
|
||||||
className={`overflow-hidden transition-all duration-300 ease-in-out`}
|
|
||||||
>
|
|
||||||
<div className="pt-0 space-y-4">
|
|
||||||
{/* Mode Selection */}
|
|
||||||
<div>
|
|
||||||
<div className="flex rounded-md border border-gray-200 dark:border-gray-700">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={projectSetupMode === "create" ? "default" : "ghost"}
|
|
||||||
className={`flex-1 rounded-none rounded-l-md border-0 ${
|
|
||||||
projectSetupMode === "create"
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
setProjectSetupMode("create");
|
|
||||||
setCreateProjectError(null);
|
|
||||||
setCreateProjectSuccess(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Create new project
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={projectSetupMode === "existing" ? "default" : "ghost"}
|
|
||||||
className={`flex-1 rounded-none rounded-r-md border-0 border-l border-gray-200 dark:border-gray-700 ${
|
|
||||||
projectSetupMode === "existing"
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
setProjectSetupMode("existing");
|
|
||||||
setCreateProjectError(null);
|
|
||||||
setCreateProjectSuccess(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Connect to existing project
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form className="space-y-4" onSubmit={handleSetupProject}>
|
|
||||||
{projectSetupMode === "create" ? (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<Label className="block text-sm font-medium">
|
|
||||||
Project Name
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
data-testid="vercel-create-project-name-input"
|
|
||||||
className="w-full mt-1"
|
|
||||||
value={projectName}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newValue = e.target.value;
|
|
||||||
setProjectName(newValue);
|
|
||||||
setProjectAvailable(null);
|
|
||||||
setProjectCheckError(null);
|
|
||||||
debouncedCheckProjectAvailability(newValue);
|
|
||||||
}}
|
|
||||||
disabled={isCreatingProject}
|
|
||||||
/>
|
|
||||||
{isCheckingProject && (
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Checking availability...
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{projectAvailable === true && (
|
|
||||||
<p className="text-xs text-green-600 mt-1">
|
|
||||||
Project name is available!
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{projectAvailable === false && (
|
|
||||||
<p className="text-xs text-red-600 mt-1">
|
|
||||||
{projectCheckError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<Label className="block text-sm font-medium">
|
|
||||||
Select Project
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={selectedProject}
|
|
||||||
onValueChange={setSelectedProject}
|
|
||||||
disabled={isLoadingProjects}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
className="w-full mt-1"
|
|
||||||
data-testid="vercel-project-select"
|
|
||||||
>
|
|
||||||
<SelectValue
|
|
||||||
placeholder={
|
|
||||||
isLoadingProjects
|
|
||||||
? "Loading projects..."
|
|
||||||
: "Select a project"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{availableProjects.map((project) => (
|
|
||||||
<SelectItem key={project.id} value={project.id}>
|
|
||||||
{project.name}{" "}
|
|
||||||
{project.framework && `(${project.framework})`}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={
|
|
||||||
isCreatingProject ||
|
|
||||||
(projectSetupMode === "create" &&
|
|
||||||
(projectAvailable === false || !projectName)) ||
|
|
||||||
(projectSetupMode === "existing" && !selectedProject)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isCreatingProject
|
|
||||||
? projectSetupMode === "create"
|
|
||||||
? "Creating..."
|
|
||||||
: "Connecting..."
|
|
||||||
: projectSetupMode === "create"
|
|
||||||
? "Create Project"
|
|
||||||
: "Connect to Project"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{createProjectError && (
|
|
||||||
<p className="text-red-600 mt-2">{createProjectError}</p>
|
|
||||||
)}
|
|
||||||
{createProjectSuccess && (
|
|
||||||
<p className="text-green-600 mt-2">
|
|
||||||
{projectSetupMode === "create"
|
|
||||||
? "Project created and linked!"
|
|
||||||
: "Connected to project!"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VercelConnector({ appId, folderName }: VercelConnectorProps) {
|
|
||||||
const { app, refreshApp } = useLoadApp(appId);
|
|
||||||
const { settings, refreshSettings } = useSettings();
|
|
||||||
|
|
||||||
if (app?.vercelProjectId && appId) {
|
|
||||||
return (
|
|
||||||
<ConnectedVercelConnector
|
|
||||||
appId={appId}
|
|
||||||
app={app}
|
|
||||||
refreshApp={refreshApp}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<UnconnectedVercelConnector
|
|
||||||
appId={appId}
|
|
||||||
folderName={folderName}
|
|
||||||
settings={settings}
|
|
||||||
refreshSettings={refreshSettings}
|
|
||||||
refreshApp={refreshApp}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import { showSuccess, showError } from "@/lib/toast";
|
|
||||||
|
|
||||||
export function VercelIntegration() {
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
|
||||||
|
|
||||||
const handleDisconnectFromVercel = async () => {
|
|
||||||
setIsDisconnecting(true);
|
|
||||||
try {
|
|
||||||
const result = await updateSettings({
|
|
||||||
vercelAccessToken: undefined,
|
|
||||||
});
|
|
||||||
if (result) {
|
|
||||||
showSuccess("Successfully disconnected from Vercel");
|
|
||||||
} else {
|
|
||||||
showError("Failed to disconnect from Vercel");
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
showError(
|
|
||||||
err.message || "An error occurred while disconnecting from Vercel",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsDisconnecting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isConnected = !!settings?.vercelAccessToken;
|
|
||||||
|
|
||||||
if (!isConnected) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Vercel Integration
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
Your account is connected to Vercel.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleDisconnectFromVercel}
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
disabled={isDisconnecting}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{isDisconnecting ? "Disconnecting..." : "Disconnect from Vercel"}
|
|
||||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M24 22.525H0l12-21.05 12 21.05z" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
|
||||||
import { ZoomLevel, ZoomLevelSchema } from "@/lib/schemas";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
|
|
||||||
const ZOOM_LEVEL_LABELS: Record<ZoomLevel, string> = {
|
|
||||||
"90": "90%",
|
|
||||||
"100": "100%",
|
|
||||||
"110": "110%",
|
|
||||||
"125": "125%",
|
|
||||||
"150": "150%",
|
|
||||||
};
|
|
||||||
|
|
||||||
const ZOOM_LEVEL_DESCRIPTIONS: Record<ZoomLevel, string> = {
|
|
||||||
"90": "Slightly zoomed out to fit more content on screen.",
|
|
||||||
"100": "Default zoom level.",
|
|
||||||
"110": "Zoom in a little for easier reading.",
|
|
||||||
"125": "Large zoom for improved readability.",
|
|
||||||
"150": "Maximum zoom for maximum accessibility.",
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_ZOOM_LEVEL: ZoomLevel = "100";
|
|
||||||
|
|
||||||
export function ZoomSelector() {
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
const currentZoomLevel: ZoomLevel = useMemo(() => {
|
|
||||||
const value = settings?.zoomLevel ?? DEFAULT_ZOOM_LEVEL;
|
|
||||||
return ZoomLevelSchema.safeParse(value).success
|
|
||||||
? (value as ZoomLevel)
|
|
||||||
: DEFAULT_ZOOM_LEVEL;
|
|
||||||
}, [settings?.zoomLevel]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<Label htmlFor="zoom-level">Zoom level</Label>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Adjusts the zoom level to make content easier to read.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={currentZoomLevel}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ zoomLevel: value as ZoomLevel })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="zoom-level" className="w-[220px]">
|
|
||||||
<SelectValue placeholder="Select zoom level" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{Object.entries(ZOOM_LEVEL_LABELS).map(([value, label]) => (
|
|
||||||
<SelectItem key={value} value={value}>
|
|
||||||
<div className="flex flex-col text-left">
|
|
||||||
<span>{label}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{ZOOM_LEVEL_DESCRIPTIONS[value as ZoomLevel]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
import {
|
|
||||||
Home,
|
|
||||||
Inbox,
|
|
||||||
Settings,
|
|
||||||
HelpCircle,
|
|
||||||
Store,
|
|
||||||
BookOpen,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Link, useRouterState } from "@tanstack/react-router";
|
|
||||||
import { useSidebar } from "@/components/ui/sidebar"; // import useSidebar hook
|
|
||||||
import { useEffect, useState, useRef } from "react";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { dropdownOpenAtom } from "@/atoms/uiAtoms";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
|
||||||
SidebarFooter,
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
SidebarRail,
|
|
||||||
SidebarTrigger,
|
|
||||||
} from "@/components/ui/sidebar";
|
|
||||||
import { ChatList } from "./ChatList";
|
|
||||||
import { AppList } from "./AppList";
|
|
||||||
import { HelpDialog } from "./HelpDialog"; // Import the new dialog
|
|
||||||
import { SettingsList } from "./SettingsList";
|
|
||||||
|
|
||||||
// Menu items.
|
|
||||||
const items = [
|
|
||||||
{
|
|
||||||
title: "Apps",
|
|
||||||
to: "/",
|
|
||||||
icon: Home,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Chat",
|
|
||||||
to: "/chat",
|
|
||||||
icon: Inbox,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Settings",
|
|
||||||
to: "/settings",
|
|
||||||
icon: Settings,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Library",
|
|
||||||
to: "/library",
|
|
||||||
icon: BookOpen,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Hub",
|
|
||||||
to: "/hub",
|
|
||||||
icon: Store,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Hover state types
|
|
||||||
type HoverState =
|
|
||||||
| "start-hover:app"
|
|
||||||
| "start-hover:chat"
|
|
||||||
| "start-hover:settings"
|
|
||||||
| "start-hover:library"
|
|
||||||
| "clear-hover"
|
|
||||||
| "no-hover";
|
|
||||||
|
|
||||||
export function AppSidebar() {
|
|
||||||
const { state, toggleSidebar } = useSidebar(); // retrieve current sidebar state
|
|
||||||
const [hoverState, setHoverState] = useState<HoverState>("no-hover");
|
|
||||||
const expandedByHover = useRef(false);
|
|
||||||
const [isHelpDialogOpen, setIsHelpDialogOpen] = useState(false); // State for dialog
|
|
||||||
const [isDropdownOpen] = useAtom(dropdownOpenAtom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hoverState.startsWith("start-hover") && state === "collapsed") {
|
|
||||||
expandedByHover.current = true;
|
|
||||||
toggleSidebar();
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
hoverState === "clear-hover" &&
|
|
||||||
state === "expanded" &&
|
|
||||||
expandedByHover.current &&
|
|
||||||
!isDropdownOpen
|
|
||||||
) {
|
|
||||||
toggleSidebar();
|
|
||||||
expandedByHover.current = false;
|
|
||||||
setHoverState("no-hover");
|
|
||||||
}
|
|
||||||
}, [hoverState, toggleSidebar, state, setHoverState, isDropdownOpen]);
|
|
||||||
|
|
||||||
const routerState = useRouterState();
|
|
||||||
const isAppRoute =
|
|
||||||
routerState.location.pathname === "/" ||
|
|
||||||
routerState.location.pathname.startsWith("/app-details");
|
|
||||||
const isChatRoute = routerState.location.pathname === "/chat";
|
|
||||||
const isSettingsRoute = routerState.location.pathname.startsWith("/settings");
|
|
||||||
|
|
||||||
let selectedItem: string | null = null;
|
|
||||||
if (hoverState === "start-hover:app") {
|
|
||||||
selectedItem = "Apps";
|
|
||||||
} else if (hoverState === "start-hover:chat") {
|
|
||||||
selectedItem = "Chat";
|
|
||||||
} else if (hoverState === "start-hover:settings") {
|
|
||||||
selectedItem = "Settings";
|
|
||||||
} else if (hoverState === "start-hover:library") {
|
|
||||||
selectedItem = "Library";
|
|
||||||
} else if (state === "expanded") {
|
|
||||||
if (isAppRoute) {
|
|
||||||
selectedItem = "Apps";
|
|
||||||
} else if (isChatRoute) {
|
|
||||||
selectedItem = "Chat";
|
|
||||||
} else if (isSettingsRoute) {
|
|
||||||
selectedItem = "Settings";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sidebar
|
|
||||||
collapsible="icon"
|
|
||||||
onMouseLeave={() => {
|
|
||||||
if (!isDropdownOpen) {
|
|
||||||
setHoverState("clear-hover");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SidebarContent className="overflow-hidden">
|
|
||||||
<div className="flex mt-8">
|
|
||||||
{/* Left Column: Menu items */}
|
|
||||||
<div className="">
|
|
||||||
<SidebarTrigger
|
|
||||||
onMouseEnter={() => {
|
|
||||||
setHoverState("clear-hover");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<AppIcons onHoverChange={setHoverState} />
|
|
||||||
</div>
|
|
||||||
{/* Right Column: Chat List Section */}
|
|
||||||
<div className="w-[240px]">
|
|
||||||
<AppList show={selectedItem === "Apps"} />
|
|
||||||
<ChatList show={selectedItem === "Chat"} />
|
|
||||||
<SettingsList show={selectedItem === "Settings"} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SidebarContent>
|
|
||||||
|
|
||||||
<SidebarFooter>
|
|
||||||
<SidebarMenu>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
{/* Change button to open dialog instead of linking */}
|
|
||||||
<SidebarMenuButton
|
|
||||||
size="sm"
|
|
||||||
className="font-medium w-14 flex flex-col items-center gap-1 h-14 mb-2 rounded-2xl"
|
|
||||||
onClick={() => setIsHelpDialogOpen(true)} // Open dialog on click
|
|
||||||
>
|
|
||||||
<HelpCircle className="h-5 w-5" />
|
|
||||||
<span className={"text-xs"}>Help</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
<HelpDialog
|
|
||||||
isOpen={isHelpDialogOpen}
|
|
||||||
onClose={() => setIsHelpDialogOpen(false)}
|
|
||||||
/>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarFooter>
|
|
||||||
|
|
||||||
<SidebarRail />
|
|
||||||
</Sidebar>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AppIcons({
|
|
||||||
onHoverChange,
|
|
||||||
}: {
|
|
||||||
onHoverChange: (state: HoverState) => void;
|
|
||||||
}) {
|
|
||||||
const routerState = useRouterState();
|
|
||||||
const pathname = routerState.location.pathname;
|
|
||||||
|
|
||||||
return (
|
|
||||||
// When collapsed: only show the main menu
|
|
||||||
<SidebarGroup className="pr-0">
|
|
||||||
{/* <SidebarGroupLabel>Dyad</SidebarGroupLabel> */}
|
|
||||||
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu>
|
|
||||||
{items.map((item) => {
|
|
||||||
const isActive =
|
|
||||||
(item.to === "/" && pathname === "/") ||
|
|
||||||
(item.to !== "/" && pathname.startsWith(item.to));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem key={item.title}>
|
|
||||||
<SidebarMenuButton
|
|
||||||
asChild
|
|
||||||
size="sm"
|
|
||||||
className="font-medium w-14"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
to={item.to}
|
|
||||||
className={`flex flex-col items-center gap-1 h-14 mb-2 rounded-2xl ${
|
|
||||||
isActive ? "bg-sidebar-accent" : ""
|
|
||||||
}`}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
if (item.title === "Apps") {
|
|
||||||
onHoverChange("start-hover:app");
|
|
||||||
} else if (item.title === "Chat") {
|
|
||||||
onHoverChange("start-hover:chat");
|
|
||||||
} else if (item.title === "Settings") {
|
|
||||||
onHoverChange("start-hover:settings");
|
|
||||||
} else if (item.title === "Library") {
|
|
||||||
onHoverChange("start-hover:library");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<item.icon className="h-5 w-5" />
|
|
||||||
<span className={"text-xs"}>{item.title}</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { formatDistanceToNow } from "date-fns";
|
|
||||||
import { Star } from "lucide-react";
|
|
||||||
import { SidebarMenuItem } from "@/components/ui/sidebar";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { App } from "@/ipc/ipc_types";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
|
|
||||||
type AppItemProps = {
|
|
||||||
app: App;
|
|
||||||
handleAppClick: (id: number) => void;
|
|
||||||
selectedAppId: number | null;
|
|
||||||
handleToggleFavorite: (appId: number, e: React.MouseEvent) => void;
|
|
||||||
isFavoriteLoading: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AppItem({
|
|
||||||
app,
|
|
||||||
handleAppClick,
|
|
||||||
selectedAppId,
|
|
||||||
handleToggleFavorite,
|
|
||||||
isFavoriteLoading,
|
|
||||||
}: AppItemProps) {
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem className="mb-1 relative ">
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex w-[190px] items-center">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleAppClick(app.id)}
|
|
||||||
className={`justify-start w-full text-left py-3 hover:bg-sidebar-accent/80 ${
|
|
||||||
selectedAppId === app.id
|
|
||||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
data-testid={`app-list-item-${app.name}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col w-4/5">
|
|
||||||
<span className="truncate">{app.name}</span>
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
{formatDistanceToNow(new Date(app.createdAt), {
|
|
||||||
addSuffix: true,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => handleToggleFavorite(app.id, e)}
|
|
||||||
disabled={isFavoriteLoading}
|
|
||||||
className="absolute top-1 right-1 p-1 mx-1 h-6 w-6 z-10"
|
|
||||||
key={app.id}
|
|
||||||
data-testid="favorite-button"
|
|
||||||
>
|
|
||||||
<Star
|
|
||||||
size={12}
|
|
||||||
className={
|
|
||||||
app.isFavorite
|
|
||||||
? "fill-[#6c55dc] text-[#6c55dc]"
|
|
||||||
: selectedAppId === app.id
|
|
||||||
? "hover:fill-black hover:text-black"
|
|
||||||
: "hover:fill-[#6c55dc] hover:stroke-[#6c55dc] hover:text-[#6c55dc]"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">
|
|
||||||
<p>{app.name}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user