Compare commits
28 Commits
705608ae46
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fd3b6ffe5 | ||
|
|
5660de49de | ||
|
|
99b0cdf8ac | ||
|
|
7cf8317f55 | ||
|
|
2e31c508da | ||
|
|
3fd45ec253 | ||
|
|
47992f48dd | ||
|
|
91cf1e97c3 | ||
|
|
a6d6a4cdaf | ||
|
|
213def4a67 | ||
|
|
9d33f3757d | ||
|
|
a4ab1a7f84 | ||
|
|
86e4005795 | ||
|
|
70d4f5980e | ||
|
|
1ce399584e | ||
|
|
8d88460fe1 | ||
|
|
f2960a94b9 | ||
|
|
976e065fe5 | ||
|
|
5b789cb971 | ||
|
|
d3f3ac3ae1 | ||
|
|
a7bcec220a | ||
|
|
20866d5d8c | ||
|
|
352d4330ed | ||
|
|
c174778d5f | ||
|
|
4b17870049 | ||
|
|
1b678041ab | ||
|
|
6d66e13ea2 | ||
|
|
560cd1791d |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -107,6 +107,10 @@ jobs:
|
||||
# Merge reports after playwright-tests, even if some shards have failed
|
||||
if: ${{ !cancelled() }}
|
||||
needs: [test]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
actions: read
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
59
.github/workflows/playwright-comment.yml
vendored
Normal file
59
.github/workflows/playwright-comment.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
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" },
|
||||
# See https://github.com/dyad-sh/dyad/issues/96
|
||||
{ name: "linux", image: "ubuntu-22.04" },
|
||||
{ name: "macos-intel", image: "macos-13" },
|
||||
{ name: "macos-intel", image: "macos-15-intel" },
|
||||
{ name: "macos", image: "macos-latest" },
|
||||
]
|
||||
runs-on: ${{ matrix.os.image }}
|
||||
|
||||
590
README-CUSTOM-INTEGRATION.md
Normal file
590
README-CUSTOM-INTEGRATION.md
Normal file
@@ -0,0 +1,590 @@
|
||||
# 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
198
UPDATE_GUIDE.md
@@ -1,198 +0,0 @@
|
||||
# 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!
|
||||
10
backups/backup-20251218-094212/git-log.txt
Normal file
10
backups/backup-20251218-094212/git-log.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
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)
|
||||
20
backups/backup-20251218-094212/git-status.txt
Normal file
20
backups/backup-20251218-094212/git-status.txt
Normal file
@@ -0,0 +1,20 @@
|
||||
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")
|
||||
189
backups/backup-20251218-094212/package.json
Normal file
189
backups/backup-20251218-094212/package.json
Normal file
@@ -0,0 +1,189 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
77
backups/backup-20251218-094212/src/__tests__/README.md
Normal file
77
backups/backup-20251218-094212/src/__tests__/README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,127 @@
|
||||
// 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."
|
||||
`;
|
||||
@@ -0,0 +1,534 @@
|
||||
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
@@ -0,0 +1,89 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
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>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
227
backups/backup-20251218-094212/src/__tests__/path_utils.test.ts
Normal file
227
backups/backup-20251218-094212/src/__tests__/path_utils.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,409 @@
|
||||
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]",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
118
backups/backup-20251218-094212/src/__tests__/style-utils.test.ts
Normal file
118
backups/backup-20251218-094212/src/__tests__/style-utils.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
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
244
backups/backup-20251218-094212/src/app/TitleBar.tsx
Normal file
244
backups/backup-20251218-094212/src/app/TitleBar.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
97
backups/backup-20251218-094212/src/app/layout.tsx
Normal file
97
backups/backup-20251218-094212/src/app/layout.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
backups/backup-20251218-094212/src/atoms/appAtoms.ts
Normal file
28
backups/backup-20251218-094212/src/atoms/appAtoms.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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);
|
||||
24
backups/backup-20251218-094212/src/atoms/chatAtoms.ts
Normal file
24
backups/backup-20251218-094212/src/atoms/chatAtoms.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
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[]>([]);
|
||||
10
backups/backup-20251218-094212/src/atoms/localModelsAtoms.ts
Normal file
10
backups/backup-20251218-094212/src/atoms/localModelsAtoms.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
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);
|
||||
23
backups/backup-20251218-094212/src/atoms/previewAtoms.ts
Normal file
23
backups/backup-20251218-094212/src/atoms/previewAtoms.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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(),
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { atom } from "jotai";
|
||||
import type { ProposalResult } from "@/lib/schemas";
|
||||
|
||||
export const proposalResultAtom = atom<ProposalResult | null>(null);
|
||||
15
backups/backup-20251218-094212/src/atoms/supabaseAtoms.ts
Normal file
15
backups/backup-20251218-094212/src/atoms/supabaseAtoms.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
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);
|
||||
4
backups/backup-20251218-094212/src/atoms/uiAtoms.ts
Normal file
4
backups/backup-20251218-094212/src/atoms/uiAtoms.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
// Atom to track if any dropdown is currently open in the UI
|
||||
export const dropdownOpenAtom = atom<boolean>(false);
|
||||
9
backups/backup-20251218-094212/src/atoms/viewAtoms.ts
Normal file
9
backups/backup-20251218-094212/src/atoms/viewAtoms.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
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",
|
||||
);
|
||||
390
backups/backup-20251218-094212/src/backup_manager.ts
Normal file
390
backups/backup-20251218-094212/src/backup_manager.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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");
|
||||
}
|
||||
150
backups/backup-20251218-094212/src/components/AppList.tsx
Normal file
150
backups/backup-20251218-094212/src/components/AppList.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
157
backups/backup-20251218-094212/src/components/AppUpgrades.tsx
Normal file
157
backups/backup-20251218-094212/src/components/AppUpgrades.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
303
backups/backup-20251218-094212/src/components/ChatList.tsx
Normal file
303
backups/backup-20251218-094212/src/components/ChatList.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
204
backups/backup-20251218-094212/src/components/ChatPanel.tsx
Normal file
204
backups/backup-20251218-094212/src/components/ChatPanel.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
113
backups/backup-20251218-094212/src/components/ErrorBoundary.tsx
Normal file
113
backups/backup-20251218-094212/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,940 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
244
backups/backup-20251218-094212/src/components/HelpBotDialog.tsx
Normal file
244
backups/backup-20251218-094212/src/components/HelpBotDialog.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
482
backups/backup-20251218-094212/src/components/HelpDialog.tsx
Normal file
482
backups/backup-20251218-094212/src/components/HelpDialog.tsx
Normal file
@@ -0,0 +1,482 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
136
backups/backup-20251218-094212/src/components/LoadingBlock.tsx
Normal file
136
backups/backup-20251218-094212/src/components/LoadingBlock.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
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 />;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
130
backups/backup-20251218-094212/src/components/McpToolsPicker.tsx
Normal file
130
backups/backup-20251218-094212/src/components/McpToolsPicker.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
624
backups/backup-20251218-094212/src/components/ModelPicker.tsx
Normal file
624
backups/backup-20251218-094212/src/components/ModelPicker.tsx
Normal file
@@ -0,0 +1,624 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
158
backups/backup-20251218-094212/src/components/NeonConnector.tsx
Normal file
158
backups/backup-20251218-094212/src/components/NeonConnector.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
110
backups/backup-20251218-094212/src/components/PortalMigrate.tsx
Normal file
110
backups/backup-20251218-094212/src/components/PortalMigrate.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
20
backups/backup-20251218-094212/src/components/PriceBadge.tsx
Normal file
20
backups/backup-20251218-094212/src/components/PriceBadge.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
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;
|
||||
228
backups/backup-20251218-094212/src/components/ProBanner.tsx
Normal file
228
backups/backup-20251218-094212/src/components/ProBanner.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
// @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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
488
backups/backup-20251218-094212/src/components/SetupBanner.tsx
Normal file
488
backups/backup-20251218-094212/src/components/SetupBanner.tsx
Normal file
@@ -0,0 +1,488 @@
|
||||
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
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
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;
|
||||
@@ -0,0 +1,284 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
163
backups/backup-20251218-094212/src/components/TemplateCard.tsx
Normal file
163
backups/backup-20251218-094212/src/components/TemplateCard.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,659 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
231
backups/backup-20251218-094212/src/components/app-sidebar.tsx
Normal file
231
backups/backup-20251218-094212/src/components/app-sidebar.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
82
backups/backup-20251218-094212/src/components/appItem.tsx
Normal file
82
backups/backup-20251218-094212/src/components/appItem.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
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