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:
Kunthawat Greethong
2025-12-19 09:36:31 +07:00
parent 07bf4414cc
commit 756b405423
412 changed files with 69158 additions and 8 deletions

View File

@@ -523,10 +523,35 @@ const RATE_LIMIT_CONFIG = {
### 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.
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
If you see warnings about missing custom modifications:

View 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)

View 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)

View 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"
}
}
}

View 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.

View File

@@ -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."
`;

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -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>');
});
});

View File

@@ -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"]);
});
});

View File

@@ -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");
});
});
});

View 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/,
);
});
});
});

View File

@@ -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();
});
});
});

View File

@@ -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]",
};
}

View File

@@ -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");
});
});

View 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);
});
});
});

View File

@@ -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

View 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>
);
}

View 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>
</>
);
}

View 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);

View 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[]>([]);

View 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);

View 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(),
);

View File

@@ -0,0 +1,4 @@
import { atom } from "jotai";
import type { ProposalResult } from "@/lib/schemas";
export const proposalResultAtom = atom<ProposalResult | null>(null);

View 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);

View 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);

View 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",
);

View 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}`);
}
}

View File

@@ -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");
}

View 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}
/>
</>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View 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}
/>
</>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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)}
/>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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 />;
}

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
};

View 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;

View 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 410x 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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
</>
}
/>
);
};

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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}
/>
</>
);
};

View File

@@ -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>
);
};

View File

@@ -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}
/>
);
}
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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