feat(fake-llm-server): add initial setup for fake LLM server with TypeScript and Express
- Created package.json for dependencies and scripts - Added tsconfig.json for TypeScript configuration - Implemented fake stdio MCP server with basic calculator and environment variable printing tools - Added shell script to run the fake stdio MCP server - Updated root tsconfig.json for project references and path mapping
This commit is contained in:
@@ -523,10 +523,35 @@ const RATE_LIMIT_CONFIG = {
|
|||||||
|
|
||||||
### Common Issues
|
### Common Issues
|
||||||
|
|
||||||
1. **TypeScript Errors**: The script skips TypeScript compilation due to existing MCP issues. Focus on functionality first.
|
1. **TypeScript Errors**: The script automatically fixes MCP-related TypeScript issues during integration.
|
||||||
2. **Missing Custom Modifications**: The script warns if files don't contain expected custom patterns.
|
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.
|
3. **Backup Restoration**: Always restore from the most recent working backup.
|
||||||
|
|
||||||
|
### MCP TypeScript Issues
|
||||||
|
|
||||||
|
The integration script automatically handles MCP (Model Context Protocol) related TypeScript compilation errors:
|
||||||
|
|
||||||
|
**Issues Fixed:**
|
||||||
|
- `chat_stream_handlers.ts`: Adds type assertion (`as any`) for tool objects
|
||||||
|
- `mcp_handlers.ts`: Adds type assertion for tool.description property
|
||||||
|
- `mcp_manager.ts`: Replaces problematic imports with stub implementation
|
||||||
|
|
||||||
|
**Automatic Fixes:**
|
||||||
|
The `fix_mcp_typescript_issues()` function in the script:
|
||||||
|
1. Detects MCP-related type errors
|
||||||
|
2. Applies appropriate type assertions
|
||||||
|
3. Creates stub implementations for missing exports
|
||||||
|
4. Ensures compilation succeeds
|
||||||
|
|
||||||
|
**Manual Fix (if needed):**
|
||||||
|
If you encounter MCP TypeScript errors after integration:
|
||||||
|
```bash
|
||||||
|
# Re-run the integration script to fix MCP issues
|
||||||
|
./scripts/integrate-custom-features.sh integrate
|
||||||
|
|
||||||
|
# Or manually fix by adding 'as any' type assertions to tool objects
|
||||||
|
```
|
||||||
|
|
||||||
### Validation Warnings
|
### Validation Warnings
|
||||||
|
|
||||||
If you see warnings about missing custom modifications:
|
If you see warnings about missing custom modifications:
|
||||||
|
|||||||
10
backups/backup-20251218-161645/git-log.txt
Normal file
10
backups/backup-20251218-161645/git-log.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
07bf441 Merge remote-tracking branch 'upstream/main' the commit.
|
||||||
|
4fd3b6f docs: enhance integration guide with detailed step-by-step commands and troubleshooting tips
|
||||||
|
5660de4 feat: integrate custom features for smart context management
|
||||||
|
32093a4 Bump to v0.31.0-beta.1 (#1978)
|
||||||
|
656b6cb fix thinking budget e2e (#1981)
|
||||||
|
df785a8 Fix MCP e2e test (#1980)
|
||||||
|
423be75 prettier: gen playwright (#1979)
|
||||||
|
2a5cd31 gemini 3 flash (#1977)
|
||||||
|
99b0cdf feat: implement custom smart context functionality with hooks, IPC handlers, and utilities
|
||||||
|
7cf8317 Fix Playwright report comments on forked PRs (#1975)
|
||||||
9
backups/backup-20251218-161645/git-status.txt
Normal file
9
backups/backup-20251218-161645/git-status.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
On branch main
|
||||||
|
Your branch is ahead of 'origin/main' by 6 commits.
|
||||||
|
(use "git push" to publish your local commits)
|
||||||
|
|
||||||
|
Untracked files:
|
||||||
|
(use "git add <file>..." to include in what will be committed)
|
||||||
|
backups/backup-20251218-161645/
|
||||||
|
|
||||||
|
nothing added to commit but untracked files present (use "git add" to track)
|
||||||
189
backups/backup-20251218-161645/package.json
Normal file
189
backups/backup-20251218-161645/package.json
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
{
|
||||||
|
"name": "dyad",
|
||||||
|
"productName": "dyad",
|
||||||
|
"version": "0.31.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-161645/src/__tests__/README.md
Normal file
77
backups/backup-20251218-161645/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-161645/src/__tests__/path_utils.test.ts
Normal file
227
backups/backup-20251218-161645/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-161645/src/__tests__/style-utils.test.ts
Normal file
118
backups/backup-20251218-161645/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-161645/src/app/TitleBar.tsx
Normal file
244
backups/backup-20251218-161645/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-161645/src/app/layout.tsx
Normal file
97
backups/backup-20251218-161645/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-161645/src/atoms/appAtoms.ts
Normal file
28
backups/backup-20251218-161645/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-161645/src/atoms/chatAtoms.ts
Normal file
24
backups/backup-20251218-161645/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-161645/src/atoms/localModelsAtoms.ts
Normal file
10
backups/backup-20251218-161645/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-161645/src/atoms/previewAtoms.ts
Normal file
23
backups/backup-20251218-161645/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-161645/src/atoms/supabaseAtoms.ts
Normal file
15
backups/backup-20251218-161645/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-161645/src/atoms/uiAtoms.ts
Normal file
4
backups/backup-20251218-161645/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-161645/src/atoms/viewAtoms.ts
Normal file
9
backups/backup-20251218-161645/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-161645/src/backup_manager.ts
Normal file
390
backups/backup-20251218-161645/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-161645/src/components/AppList.tsx
Normal file
150
backups/backup-20251218-161645/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-161645/src/components/AppUpgrades.tsx
Normal file
157
backups/backup-20251218-161645/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-161645/src/components/ChatList.tsx
Normal file
303
backups/backup-20251218-161645/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-161645/src/components/ChatPanel.tsx
Normal file
204
backups/backup-20251218-161645/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-161645/src/components/ErrorBoundary.tsx
Normal file
113
backups/backup-20251218-161645/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-161645/src/components/HelpBotDialog.tsx
Normal file
244
backups/backup-20251218-161645/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-161645/src/components/HelpDialog.tsx
Normal file
482
backups/backup-20251218-161645/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-161645/src/components/LoadingBlock.tsx
Normal file
136
backups/backup-20251218-161645/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-161645/src/components/McpToolsPicker.tsx
Normal file
130
backups/backup-20251218-161645/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-161645/src/components/ModelPicker.tsx
Normal file
624
backups/backup-20251218-161645/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-161645/src/components/NeonConnector.tsx
Normal file
158
backups/backup-20251218-161645/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-161645/src/components/PortalMigrate.tsx
Normal file
110
backups/backup-20251218-161645/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-161645/src/components/PriceBadge.tsx
Normal file
20
backups/backup-20251218-161645/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-161645/src/components/ProBanner.tsx
Normal file
228
backups/backup-20251218-161645/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-161645/src/components/SetupBanner.tsx
Normal file
488
backups/backup-20251218-161645/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-161645/src/components/TemplateCard.tsx
Normal file
163
backups/backup-20251218-161645/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-161645/src/components/app-sidebar.tsx
Normal file
231
backups/backup-20251218-161645/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-161645/src/components/appItem.tsx
Normal file
82
backups/backup-20251218-161645/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { FileText, X, MessageSquare, Upload } from "lucide-react";
|
||||||
|
import type { FileAttachment } from "@/ipc/ipc_types";
|
||||||
|
|
||||||
|
interface AttachmentsListProps {
|
||||||
|
attachments: FileAttachment[];
|
||||||
|
onRemove: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentsList({
|
||||||
|
attachments,
|
||||||
|
onRemove,
|
||||||
|
}: AttachmentsListProps) {
|
||||||
|
if (attachments.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-2 pt-2 flex flex-wrap gap-1">
|
||||||
|
{attachments.map((attachment, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center bg-muted rounded-md px-2 py-1 text-xs gap-1"
|
||||||
|
title={`${attachment.file.name} (${(attachment.file.size / 1024).toFixed(1)}KB)`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{attachment.type === "upload-to-codebase" ? (
|
||||||
|
<Upload size={12} className="text-blue-600" />
|
||||||
|
) : (
|
||||||
|
<MessageSquare size={12} className="text-green-600" />
|
||||||
|
)}
|
||||||
|
{attachment.file.type.startsWith("image/") ? (
|
||||||
|
<div className="relative group">
|
||||||
|
<img
|
||||||
|
src={URL.createObjectURL(attachment.file)}
|
||||||
|
alt={attachment.file.name}
|
||||||
|
className="w-5 h-5 object-cover rounded"
|
||||||
|
onLoad={(e) =>
|
||||||
|
URL.revokeObjectURL((e.target as HTMLImageElement).src)
|
||||||
|
}
|
||||||
|
onError={(e) =>
|
||||||
|
URL.revokeObjectURL((e.target as HTMLImageElement).src)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="absolute hidden group-hover:block top-6 left-0 z-10">
|
||||||
|
<img
|
||||||
|
src={URL.createObjectURL(attachment.file)}
|
||||||
|
alt={attachment.file.name}
|
||||||
|
className="max-w-[200px] max-h-[200px] object-contain bg-white p-1 rounded shadow-lg"
|
||||||
|
onLoad={(e) =>
|
||||||
|
URL.revokeObjectURL((e.target as HTMLImageElement).src)
|
||||||
|
}
|
||||||
|
onError={(e) =>
|
||||||
|
URL.revokeObjectURL((e.target as HTMLImageElement).src)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FileText size={12} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="truncate max-w-[120px]">{attachment.file.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(index)}
|
||||||
|
className="hover:bg-muted-foreground/20 rounded-full p-0.5"
|
||||||
|
aria-label="Remove attachment"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { Bell, Loader2, CheckCircle2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { getAllChats } from "@/lib/chat";
|
||||||
|
import type { ChatSummary } from "@/lib/schemas";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import {
|
||||||
|
isStreamingByIdAtom,
|
||||||
|
recentStreamChatIdsAtom,
|
||||||
|
} from "@/atoms/chatAtoms";
|
||||||
|
import { useLoadApps } from "@/hooks/useLoadApps";
|
||||||
|
import { useSelectChat } from "@/hooks/useSelectChat";
|
||||||
|
|
||||||
|
export function ChatActivityButton() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const isStreamingById = useAtomValue(isStreamingByIdAtom);
|
||||||
|
const isAnyStreaming = useMemo(() => {
|
||||||
|
for (const v of isStreamingById.values()) {
|
||||||
|
if (v) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [isStreamingById]);
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="no-app-region-drag relative flex items-center justify-center p-1.5 rounded-md text-sm hover:bg-[var(--background-darkest)] transition-colors"
|
||||||
|
data-testid="chat-activity-button"
|
||||||
|
>
|
||||||
|
{isAnyStreaming && (
|
||||||
|
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="block size-7 rounded-full border-3 border-blue-500/60 border-t-transparent animate-spin" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Bell size={16} />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Recent chat activity</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<PopoverContent
|
||||||
|
align="end"
|
||||||
|
className="w-80 p-0 max-h-[50vh] overflow-y-auto"
|
||||||
|
>
|
||||||
|
<ChatActivityList onSelect={() => setOpen(false)} />
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatActivityList({ onSelect }: { onSelect?: () => void }) {
|
||||||
|
const [chats, setChats] = useState<ChatSummary[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const isStreamingById = useAtomValue(isStreamingByIdAtom);
|
||||||
|
const recentStreamChatIds = useAtomValue(recentStreamChatIdsAtom);
|
||||||
|
const apps = useLoadApps();
|
||||||
|
const { selectChat } = useSelectChat();
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const all = await getAllChats();
|
||||||
|
if (!mounted) return;
|
||||||
|
const recent = Array.from(recentStreamChatIds)
|
||||||
|
.map((id) => all.find((c) => c.id === id))
|
||||||
|
.filter((c) => c !== undefined);
|
||||||
|
// Sort recent first
|
||||||
|
setChats([...recent].reverse());
|
||||||
|
} finally {
|
||||||
|
if (mounted) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [recentStreamChatIds]);
|
||||||
|
|
||||||
|
const rows = useMemo(() => chats.slice(0, 30), [chats]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 text-sm text-muted-foreground flex items-center gap-2">
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
Loading activity…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 text-sm text-muted-foreground">No recent chats</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-1" data-testid="chat-activity-list">
|
||||||
|
{rows.map((c) => {
|
||||||
|
const inProgress = isStreamingById.get(c.id) === true;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
className="w-full text-left px-3 py-2 flex items-center justify-between gap-2 rounded-md hover:bg-[var(--background-darker)] dark:hover:bg-[var(--background-lighter)] transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect?.();
|
||||||
|
selectChat({ chatId: c.id, appId: c.appId });
|
||||||
|
}}
|
||||||
|
data-testid={`chat-activity-list-item-${c.id}`}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium">
|
||||||
|
{c.title ?? `Chat #${c.id}`}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{apps.apps.find((a) => a.id === c.appId)?.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{inProgress ? (
|
||||||
|
<div className="flex items-center text-purple-600">
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center text-emerald-600">
|
||||||
|
<CheckCircle2 size={16} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { XCircle, AlertTriangle } from "lucide-react"; // Assuming lucide-react is used
|
||||||
|
|
||||||
|
interface ChatErrorProps {
|
||||||
|
error: string | null;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatError({ error, onDismiss }: ChatErrorProps) {
|
||||||
|
if (!error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-start text-red-600 bg-red-100 border border-red-500 rounded-md text-sm p-3 mx-4 mb-2 shadow-sm">
|
||||||
|
<AlertTriangle
|
||||||
|
className="h-5 w-5 mr-2 flex-shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="flex-1">{error}</span>
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="absolute top-1 right-1 p-1 rounded-full hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-red-400"
|
||||||
|
aria-label="Dismiss error"
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4 text-red-500 hover:text-red-700" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { IpcClient } from "@/ipc/ipc_client";
|
||||||
|
import { AI_STREAMING_ERROR_MESSAGE_PREFIX } from "@/shared/texts";
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
ExternalLink as ExternalLinkIcon,
|
||||||
|
CircleArrowUp,
|
||||||
|
} from "lucide-react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
|
export function ChatErrorBox({
|
||||||
|
onDismiss,
|
||||||
|
error,
|
||||||
|
isDyadProEnabled,
|
||||||
|
}: {
|
||||||
|
onDismiss: () => void;
|
||||||
|
error: string;
|
||||||
|
isDyadProEnabled: boolean;
|
||||||
|
}) {
|
||||||
|
if (error.includes("doesn't have a free quota tier")) {
|
||||||
|
return (
|
||||||
|
<ChatErrorContainer onDismiss={onDismiss}>
|
||||||
|
{error}
|
||||||
|
<span className="ml-1">
|
||||||
|
<ExternalLink
|
||||||
|
href="https://dyad.sh/pro?utm_source=dyad-app&utm_medium=app&utm_campaign=free-quota-error"
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
Access with Dyad Pro
|
||||||
|
</ExternalLink>
|
||||||
|
</span>{" "}
|
||||||
|
or switch to another model.
|
||||||
|
</ChatErrorContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Important, this needs to come after the "free quota tier" check
|
||||||
|
// because it also includes this URL in the error message
|
||||||
|
//
|
||||||
|
// Sometimes Dyad Pro can return rate limit errors and we do not want to
|
||||||
|
// show the upgrade to Dyad Pro link in that case because they are
|
||||||
|
// already on the Dyad Pro plan.
|
||||||
|
if (
|
||||||
|
!isDyadProEnabled &&
|
||||||
|
(error.includes("Resource has been exhausted") ||
|
||||||
|
error.includes("https://ai.google.dev/gemini-api/docs/rate-limits") ||
|
||||||
|
error.includes("Provider returned error"))
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<ChatErrorContainer onDismiss={onDismiss}>
|
||||||
|
{error}
|
||||||
|
<div className="mt-2 space-y-2 space-x-2">
|
||||||
|
<ExternalLink
|
||||||
|
href="https://dyad.sh/pro?utm_source=dyad-app&utm_medium=app&utm_campaign=rate-limit-error"
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
Upgrade to Dyad Pro
|
||||||
|
</ExternalLink>
|
||||||
|
|
||||||
|
<ExternalLink href="https://dyad.sh/docs/help/ai-rate-limit">
|
||||||
|
Troubleshooting guide
|
||||||
|
</ExternalLink>
|
||||||
|
</div>
|
||||||
|
</ChatErrorContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.includes("LiteLLM Virtual Key expected")) {
|
||||||
|
return (
|
||||||
|
<ChatInfoContainer onDismiss={onDismiss}>
|
||||||
|
<span>
|
||||||
|
Looks like you don't have a valid Dyad Pro key.{" "}
|
||||||
|
<ExternalLink
|
||||||
|
href="https://dyad.sh/pro?utm_source=dyad-app&utm_medium=app&utm_campaign=invalid-pro-key-error"
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
Upgrade to Dyad Pro
|
||||||
|
</ExternalLink>{" "}
|
||||||
|
today.
|
||||||
|
</span>
|
||||||
|
</ChatInfoContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isDyadProEnabled && error.includes("ExceededBudget:")) {
|
||||||
|
return (
|
||||||
|
<ChatInfoContainer onDismiss={onDismiss}>
|
||||||
|
<span>
|
||||||
|
You have used all of your Dyad AI credits this month.{" "}
|
||||||
|
<ExternalLink
|
||||||
|
href="https://academy.dyad.sh/subscription?utm_source=dyad-app&utm_medium=app&utm_campaign=exceeded-budget-error"
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
Reload or upgrade your subscription
|
||||||
|
</ExternalLink>{" "}
|
||||||
|
and get more AI credits
|
||||||
|
</span>
|
||||||
|
</ChatInfoContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// This is a very long list of model fallbacks that clutters the error message.
|
||||||
|
//
|
||||||
|
// We are matching "Fallbacks=[{" and not just "Fallbacks=" because the fallback
|
||||||
|
// model itself can error and we want to include the fallback model error in the error message.
|
||||||
|
// Example: https://github.com/dyad-sh/dyad/issues/1849#issuecomment-3590685911
|
||||||
|
const fallbackPrefix = "Fallbacks=[{";
|
||||||
|
if (error.includes(fallbackPrefix)) {
|
||||||
|
error = error.split(fallbackPrefix)[0];
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ChatErrorContainer onDismiss={onDismiss}>
|
||||||
|
{error}
|
||||||
|
<div className="mt-2 space-y-2 space-x-2">
|
||||||
|
{!isDyadProEnabled &&
|
||||||
|
error.includes(AI_STREAMING_ERROR_MESSAGE_PREFIX) &&
|
||||||
|
!error.includes("TypeError: terminated") && (
|
||||||
|
<ExternalLink
|
||||||
|
href="https://dyad.sh/pro?utm_source=dyad-app&utm_medium=app&utm_campaign=general-error"
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
Upgrade to Dyad Pro
|
||||||
|
</ExternalLink>
|
||||||
|
)}
|
||||||
|
<ExternalLink href="https://www.dyad.sh/docs/faq">
|
||||||
|
Read docs
|
||||||
|
</ExternalLink>
|
||||||
|
</div>
|
||||||
|
</ChatErrorContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExternalLink({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
variant = "secondary",
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
variant?: "primary" | "secondary";
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const baseClasses =
|
||||||
|
"cursor-pointer inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium shadow-sm focus:outline-none focus:ring-2";
|
||||||
|
const primaryClasses =
|
||||||
|
"bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500";
|
||||||
|
const secondaryClasses =
|
||||||
|
"bg-blue-50 text-blue-700 border border-blue-200 hover:bg-blue-100 hover:border-blue-300 focus:ring-blue-200";
|
||||||
|
const iconElement =
|
||||||
|
icon ??
|
||||||
|
(variant === "primary" ? (
|
||||||
|
<CircleArrowUp size={18} />
|
||||||
|
) : (
|
||||||
|
<ExternalLinkIcon size={14} />
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className={`${baseClasses} ${
|
||||||
|
variant === "primary" ? primaryClasses : secondaryClasses
|
||||||
|
}`}
|
||||||
|
onClick={() => IpcClient.getInstance().openExternalUrl(href)}
|
||||||
|
>
|
||||||
|
<span>{children}</span>
|
||||||
|
{iconElement}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatErrorContainer({
|
||||||
|
onDismiss,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onDismiss: () => void;
|
||||||
|
children: React.ReactNode | string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative mt-2 bg-red-50 border border-red-200 rounded-md shadow-sm p-2 mx-4">
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="absolute top-2.5 left-2 p-1 hover:bg-red-100 rounded"
|
||||||
|
>
|
||||||
|
<X size={14} className="text-red-500" />
|
||||||
|
</button>
|
||||||
|
<div className="pl-8 py-1 text-sm">
|
||||||
|
<div className="text-red-700 text-wrap">
|
||||||
|
{typeof children === "string" ? (
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
a: ({ children: linkChildren, ...props }) => (
|
||||||
|
<a
|
||||||
|
{...props}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (props.href) {
|
||||||
|
IpcClient.getInstance().openExternalUrl(props.href);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
{linkChildren}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ReactMarkdown>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatInfoContainer({
|
||||||
|
onDismiss,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onDismiss: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative mt-2 bg-sky-50 border border-sky-200 rounded-md shadow-sm p-2 mx-4">
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="absolute top-2.5 left-2 p-1 hover:bg-sky-100 rounded"
|
||||||
|
>
|
||||||
|
<X size={14} className="text-sky-600" />
|
||||||
|
</button>
|
||||||
|
<div className="pl-8 py-1 text-sm">
|
||||||
|
<div className="text-sky-800 text-wrap">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user