Add project files
This commit is contained in:
77
src/__tests__/README.md
Normal file
77
src/__tests__/README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Test Documentation
|
||||
|
||||
This directory contains unit tests for the Dyad application.
|
||||
|
||||
## Testing Setup
|
||||
|
||||
We use [Vitest](https://vitest.dev/) as our testing framework, which is designed to work well with Vite and modern JavaScript.
|
||||
|
||||
### Test Commands
|
||||
|
||||
Add these commands to your `package.json`:
|
||||
|
||||
```json
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui"
|
||||
```
|
||||
|
||||
- `npm run test` - Run tests once
|
||||
- `npm run test:watch` - Run tests in watch mode (rerun when files change)
|
||||
- `npm run test:ui` - Run tests with UI reporter
|
||||
|
||||
## Mocking Guidelines
|
||||
|
||||
### Mocking fs module
|
||||
|
||||
When mocking the `node:fs` module, use a default export in the mock:
|
||||
|
||||
```typescript
|
||||
vi.mock("node:fs", async () => {
|
||||
return {
|
||||
default: {
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
// Add other fs methods as needed
|
||||
},
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
### Mocking isomorphic-git
|
||||
|
||||
When mocking isomorphic-git, provide a default export:
|
||||
|
||||
```typescript
|
||||
vi.mock("isomorphic-git", () => ({
|
||||
default: {
|
||||
add: vi.fn().mockResolvedValue(undefined),
|
||||
commit: vi.fn().mockResolvedValue(undefined),
|
||||
// Add other git methods as needed
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
### Testing IPC Handlers
|
||||
|
||||
When testing IPC handlers, mock the Electron IPC system:
|
||||
|
||||
```typescript
|
||||
vi.mock("electron", () => ({
|
||||
ipcMain: {
|
||||
handle: vi.fn(),
|
||||
on: vi.fn(),
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
1. Create a new file with the `.test.ts` or `.spec.ts` extension
|
||||
2. Import the functions you want to test
|
||||
3. Mock any dependencies using `vi.mock()`
|
||||
4. Write your test cases using `describe()` and `it()`
|
||||
|
||||
## Example
|
||||
|
||||
See `chat_stream_handlers.test.ts` for an example of testing IPC handlers with proper mocking.
|
||||
127
src/__tests__/__snapshots__/problem_prompt.test.ts.snap
Normal file
127
src/__tests__/__snapshots__/problem_prompt.test.ts.snap
Normal 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."
|
||||
`;
|
||||
534
src/__tests__/app_env_vars_utils.test.ts
Normal file
534
src/__tests__/app_env_vars_utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
1208
src/__tests__/chat_stream_handlers.test.ts
Normal file
1208
src/__tests__/chat_stream_handlers.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
89
src/__tests__/cleanFullResponse.test.ts
Normal file
89
src/__tests__/cleanFullResponse.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
166
src/__tests__/formatMessagesForSummary.test.ts
Normal file
166
src/__tests__/formatMessagesForSummary.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { formatMessagesForSummary } from "../ipc/handlers/chat_stream_handlers";
|
||||
|
||||
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>');
|
||||
});
|
||||
});
|
||||
227
src/__tests__/mention_apps.test.ts
Normal file
227
src/__tests__/mention_apps.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
147
src/__tests__/parseOllamaHost.test.ts
Normal file
147
src/__tests__/parseOllamaHost.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
227
src/__tests__/path_utils.test.ts
Normal file
227
src/__tests__/path_utils.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { safeJoin } from "@/ipc/utils/path_utils";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
describe("safeJoin", () => {
|
||||
const testBaseDir = "/app/workspace";
|
||||
const testBaseDirWindows = "C:\\app\\workspace";
|
||||
|
||||
describe("safe paths", () => {
|
||||
it("should join simple relative paths", () => {
|
||||
const result = safeJoin(testBaseDir, "src", "components", "Button.tsx");
|
||||
expect(result).toBe(
|
||||
path.join(testBaseDir, "src", "components", "Button.tsx"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle single file names", () => {
|
||||
const result = safeJoin(testBaseDir, "package.json");
|
||||
expect(result).toBe(path.join(testBaseDir, "package.json"));
|
||||
});
|
||||
|
||||
it("should handle nested directories", () => {
|
||||
const result = safeJoin(testBaseDir, "src/pages/home/index.tsx");
|
||||
expect(result).toBe(path.join(testBaseDir, "src/pages/home/index.tsx"));
|
||||
});
|
||||
|
||||
it("should handle paths with dots in filename", () => {
|
||||
const result = safeJoin(testBaseDir, "config.test.js");
|
||||
expect(result).toBe(path.join(testBaseDir, "config.test.js"));
|
||||
});
|
||||
|
||||
it("should handle empty path segments", () => {
|
||||
const result = safeJoin(testBaseDir, "", "src", "", "file.ts");
|
||||
expect(result).toBe(path.join(testBaseDir, "", "src", "", "file.ts"));
|
||||
});
|
||||
|
||||
it("should handle multiple path segments", () => {
|
||||
const result = safeJoin(testBaseDir, "a", "b", "c", "d", "file.txt");
|
||||
expect(result).toBe(
|
||||
path.join(testBaseDir, "a", "b", "c", "d", "file.txt"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should work with actual temp directory", () => {
|
||||
const tempDir = os.tmpdir();
|
||||
const result = safeJoin(tempDir, "test", "file.txt");
|
||||
expect(result).toBe(path.join(tempDir, "test", "file.txt"));
|
||||
});
|
||||
|
||||
it("should handle Windows-style relative paths with backslashes", () => {
|
||||
const result = safeJoin(testBaseDir, "src\\components\\Button.tsx");
|
||||
expect(result).toBe(
|
||||
path.join(testBaseDir, "src\\components\\Button.tsx"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle mixed forward/backslashes in relative paths", () => {
|
||||
const result = safeJoin(testBaseDir, "src/components\\ui/button.tsx");
|
||||
expect(result).toBe(
|
||||
path.join(testBaseDir, "src/components\\ui/button.tsx"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle Windows-style nested directories", () => {
|
||||
const result = safeJoin(
|
||||
testBaseDir,
|
||||
"pages\\home\\components\\index.tsx",
|
||||
);
|
||||
expect(result).toBe(
|
||||
path.join(testBaseDir, "pages\\home\\components\\index.tsx"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle relative paths starting with dot and backslash", () => {
|
||||
const result = safeJoin(testBaseDir, ".\\src\\file.txt");
|
||||
expect(result).toBe(path.join(testBaseDir, ".\\src\\file.txt"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsafe paths - directory traversal", () => {
|
||||
it("should throw on simple parent directory traversal", () => {
|
||||
expect(() => safeJoin(testBaseDir, "../outside.txt")).toThrow(
|
||||
/would escape the base directory/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw on multiple parent directory traversals", () => {
|
||||
expect(() => safeJoin(testBaseDir, "../../etc/passwd")).toThrow(
|
||||
/would escape the base directory/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw on complex traversal paths", () => {
|
||||
expect(() => safeJoin(testBaseDir, "src/../../../etc/passwd")).toThrow(
|
||||
/would escape the base directory/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw on mixed traversal with valid components", () => {
|
||||
expect(() =>
|
||||
safeJoin(
|
||||
testBaseDir,
|
||||
"src",
|
||||
"components",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"outside.txt",
|
||||
),
|
||||
).toThrow(/would escape the base directory/);
|
||||
});
|
||||
|
||||
it("should throw on absolute Unix paths", () => {
|
||||
expect(() => safeJoin(testBaseDir, "/etc/passwd")).toThrow(
|
||||
/would escape the base directory/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw on absolute Windows paths", () => {
|
||||
expect(() =>
|
||||
safeJoin(testBaseDir, "C:\\Windows\\System32\\config"),
|
||||
).toThrow(/would escape the base directory/);
|
||||
});
|
||||
|
||||
it("should throw on Windows UNC paths", () => {
|
||||
expect(() =>
|
||||
safeJoin(testBaseDir, "\\\\server\\share\\file.txt"),
|
||||
).toThrow(/would escape the base directory/);
|
||||
});
|
||||
|
||||
it("should throw on home directory shortcuts", () => {
|
||||
expect(() => safeJoin(testBaseDir, "~/secrets.txt")).toThrow(
|
||||
/would escape the base directory/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle Windows-style base paths", () => {
|
||||
const result = safeJoin(testBaseDirWindows, "src", "file.txt");
|
||||
expect(result).toBe(path.join(testBaseDirWindows, "src", "file.txt"));
|
||||
});
|
||||
|
||||
it("should throw on Windows traversal from Unix base", () => {
|
||||
expect(() => safeJoin(testBaseDir, "..\\..\\file.txt")).toThrow(
|
||||
/would escape the base directory/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle current directory references safely", () => {
|
||||
const result = safeJoin(testBaseDir, "./src/file.txt");
|
||||
expect(result).toBe(path.join(testBaseDir, "./src/file.txt"));
|
||||
});
|
||||
|
||||
it("should handle nested current directory references", () => {
|
||||
const result = safeJoin(testBaseDir, "src/./components/./Button.tsx");
|
||||
expect(result).toBe(
|
||||
path.join(testBaseDir, "src/./components/./Button.tsx"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw when current dir plus traversal escapes", () => {
|
||||
expect(() => safeJoin(testBaseDir, "./../../outside.txt")).toThrow(
|
||||
/would escape the base directory/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle very long paths safely", () => {
|
||||
const longPath = Array(50).fill("subdir").join("/") + "/file.txt";
|
||||
const result = safeJoin(testBaseDir, longPath);
|
||||
expect(result).toBe(path.join(testBaseDir, longPath));
|
||||
});
|
||||
|
||||
it("should allow Windows-style paths that look like drive letters but aren't", () => {
|
||||
// These look like they could be problematic but are actually safe relative paths
|
||||
const result1 = safeJoin(testBaseDir, "C_drive\\file.txt");
|
||||
expect(result1).toBe(path.join(testBaseDir, "C_drive\\file.txt"));
|
||||
|
||||
const result2 = safeJoin(testBaseDir, "src\\C-file.txt");
|
||||
expect(result2).toBe(path.join(testBaseDir, "src\\C-file.txt"));
|
||||
});
|
||||
|
||||
it("should handle Windows paths with multiple backslashes (not UNC)", () => {
|
||||
// Single backslashes in the middle are fine - it's only \\ at the start that's UNC
|
||||
const result = safeJoin(testBaseDir, "src\\\\components\\\\Button.tsx");
|
||||
expect(result).toBe(
|
||||
path.join(testBaseDir, "src\\\\components\\\\Button.tsx"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should provide descriptive error messages", () => {
|
||||
expect(() => safeJoin("/base", "../outside.txt")).toThrow(
|
||||
'Unsafe path: joining "../outside.txt" with base "/base" would escape the base directory',
|
||||
);
|
||||
});
|
||||
|
||||
it("should provide descriptive error for multiple segments", () => {
|
||||
expect(() => safeJoin("/base", "src", "..", "..", "outside.txt")).toThrow(
|
||||
'Unsafe path: joining "src, .., .., outside.txt" with base "/base" would escape the base directory',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("boundary conditions", () => {
|
||||
it("should allow paths at the exact boundary", () => {
|
||||
const result = safeJoin(testBaseDir, ".");
|
||||
expect(result).toBe(path.join(testBaseDir, "."));
|
||||
});
|
||||
|
||||
it("should handle paths that approach but don't cross boundary", () => {
|
||||
const result = safeJoin(testBaseDir, "deep/nested/../file.txt");
|
||||
expect(result).toBe(path.join(testBaseDir, "deep/nested/../file.txt"));
|
||||
});
|
||||
|
||||
it("should handle root directory as base", () => {
|
||||
const result = safeJoin("/", "tmp/file.txt");
|
||||
expect(result).toBe(path.join("/", "tmp/file.txt"));
|
||||
});
|
||||
|
||||
it("should throw when trying to escape root", () => {
|
||||
expect(() => safeJoin("/tmp", "../etc/passwd")).toThrow(
|
||||
/would escape the base directory/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
232
src/__tests__/problem_prompt.test.ts
Normal file
232
src/__tests__/problem_prompt.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
405
src/__tests__/readSettings.test.ts
Normal file
405
src/__tests__/readSettings.test.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
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,
|
||||
"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,
|
||||
"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]",
|
||||
};
|
||||
}
|
||||
41
src/__tests__/replacePromptReference.test.ts
Normal file
41
src/__tests__/replacePromptReference.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
251
src/app/TitleBar.tsx
Normal file
251
src/app/TitleBar.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
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_transparent.png";
|
||||
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 { PreviewHeader } from "@/components/preview_panel/PreviewHeader";
|
||||
|
||||
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 } = useDeepLink();
|
||||
useEffect(() => {
|
||||
const handleDeepLink = async () => {
|
||||
if (lastDeepLink?.type === "dyad-pro-return") {
|
||||
await refreshSettings();
|
||||
showDyadProSuccessDialog();
|
||||
}
|
||||
};
|
||||
handleDeepLink();
|
||||
}, [lastDeepLink]);
|
||||
|
||||
// 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">
|
||||
<PreviewHeader />
|
||||
</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>
|
||||
You have used {Math.round(userBudget.usedCredits)} credits out of{" "}
|
||||
{userBudget.totalCredits}.
|
||||
</p>
|
||||
<p>
|
||||
Your budget resets on{" "}
|
||||
{userBudget.budgetResetDate.toLocaleDateString()}
|
||||
</p>
|
||||
<p>Note: there is a slight delay in updating the credit status.</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
56
src/app/layout.tsx
Normal file
56
src/app/layout.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
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 } from "react";
|
||||
import { useRunApp } from "@/hooks/useRunApp";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { previewModeAtom } from "@/atoms/appAtoms";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { refreshAppIframe } = useRunApp();
|
||||
const previewMode = useAtomValue(previewModeAtom);
|
||||
// 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]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ThemeProvider>
|
||||
<DeepLinkProvider>
|
||||
<SidebarProvider>
|
||||
<TitleBar />
|
||||
<AppSidebar />
|
||||
<div 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
26
src/atoms/appAtoms.ts
Normal file
26
src/atoms/appAtoms.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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"
|
||||
>("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<string | undefined>(undefined);
|
||||
21
src/atoms/chatAtoms.ts
Normal file
21
src/atoms/chatAtoms.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Message } from "@/ipc/ipc_types";
|
||||
import { atom } from "jotai";
|
||||
import type { ChatSummary } from "@/lib/schemas";
|
||||
|
||||
// Atom to hold the chat history
|
||||
export const chatMessagesAtom = atom<Message[]>([]);
|
||||
export const chatErrorAtom = atom<string | null>(null);
|
||||
|
||||
// Atom to hold the currently selected chat ID
|
||||
export const selectedChatIdAtom = atom<number | null>(null);
|
||||
|
||||
export const isStreamingAtom = atom<boolean>(false);
|
||||
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
|
||||
export const chatStreamCountAtom = atom<number>(0);
|
||||
10
src/atoms/localModelsAtoms.ts
Normal file
10
src/atoms/localModelsAtoms.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { atom } from "jotai";
|
||||
import { type LocalModel } from "@/ipc/ipc_types";
|
||||
|
||||
export const localModelsAtom = atom<LocalModel[]>([]);
|
||||
export const localModelsLoadingAtom = atom<boolean>(false);
|
||||
export const localModelsErrorAtom = atom<Error | null>(null);
|
||||
|
||||
export const lmStudioModelsAtom = atom<LocalModel[]>([]);
|
||||
export const lmStudioModelsLoadingAtom = atom<boolean>(false);
|
||||
export const lmStudioModelsErrorAtom = atom<Error | null>(null);
|
||||
6
src/atoms/previewAtoms.ts
Normal file
6
src/atoms/previewAtoms.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ComponentSelection } from "@/ipc/ipc_types";
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const selectedComponentPreviewAtom = atom<ComponentSelection | null>(
|
||||
null,
|
||||
);
|
||||
4
src/atoms/proposalAtoms.ts
Normal file
4
src/atoms/proposalAtoms.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { atom } from "jotai";
|
||||
import type { ProposalResult } from "@/lib/schemas";
|
||||
|
||||
export const proposalResultAtom = atom<ProposalResult | null>(null);
|
||||
13
src/atoms/supabaseAtoms.ts
Normal file
13
src/atoms/supabaseAtoms.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
// Define atom for storing the list of Supabase projects
|
||||
export const supabaseProjectsAtom = atom<any[]>([]);
|
||||
|
||||
// Define atom for tracking loading state
|
||||
export const supabaseLoadingAtom = atom<boolean>(false);
|
||||
|
||||
// Define atom for storing any error that occurs during loading
|
||||
export const supabaseErrorAtom = atom<Error | null>(null);
|
||||
|
||||
// Define atom for storing the currently selected Supabase project
|
||||
export const selectedSupabaseProjectAtom = atom<string | null>(null);
|
||||
4
src/atoms/uiAtoms.ts
Normal file
4
src/atoms/uiAtoms.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
// Atom to track if any dropdown is currently open in the UI
|
||||
export const dropdownOpenAtom = atom<boolean>(false);
|
||||
6
src/atoms/viewAtoms.ts
Normal file
6
src/atoms/viewAtoms.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const isPreviewOpenAtom = atom(true);
|
||||
export const selectedFileAtom = atom<{
|
||||
path: string;
|
||||
} | null>(null);
|
||||
390
src/backup_manager.ts
Normal file
390
src/backup_manager.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import * as path from "path";
|
||||
import * as fs from "fs/promises";
|
||||
import { app } from "electron";
|
||||
import * as crypto from "crypto";
|
||||
import log from "electron-log";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const logger = log.scope("backup_manager");
|
||||
|
||||
const MAX_BACKUPS = 3;
|
||||
|
||||
interface BackupManagerOptions {
|
||||
settingsFile: string;
|
||||
dbFile: string;
|
||||
}
|
||||
|
||||
interface BackupMetadata {
|
||||
version: string;
|
||||
timestamp: string;
|
||||
reason: string;
|
||||
files: {
|
||||
settings: boolean;
|
||||
database: boolean;
|
||||
};
|
||||
checksums: {
|
||||
settings: string | null;
|
||||
database: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface BackupInfo extends BackupMetadata {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class BackupManager {
|
||||
private readonly maxBackups: number;
|
||||
private readonly settingsFilePath: string;
|
||||
private readonly dbFilePath: string;
|
||||
private userDataPath!: string;
|
||||
private backupBasePath!: string;
|
||||
|
||||
constructor(options: BackupManagerOptions) {
|
||||
this.maxBackups = MAX_BACKUPS;
|
||||
this.settingsFilePath = options.settingsFile;
|
||||
this.dbFilePath = options.dbFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize backup system - call this on app ready
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
logger.info("Initializing backup system...");
|
||||
|
||||
// Set paths after app is ready
|
||||
this.userDataPath = app.getPath("userData");
|
||||
this.backupBasePath = path.join(this.userDataPath, "backups");
|
||||
|
||||
logger.info(
|
||||
`Backup system paths - UserData: ${this.userDataPath}, Backups: ${this.backupBasePath}`,
|
||||
);
|
||||
|
||||
// Check if this is a version upgrade
|
||||
const currentVersion = app.getVersion();
|
||||
const lastVersion = await this.getLastRunVersion();
|
||||
|
||||
if (lastVersion === null) {
|
||||
logger.info("No previous version found, skipping backup");
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastVersion === currentVersion) {
|
||||
logger.info(
|
||||
`No version upgrade detected. Current version: ${currentVersion}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure backup directory exists
|
||||
await fs.mkdir(this.backupBasePath, { recursive: true });
|
||||
logger.debug("Backup directory created/verified");
|
||||
|
||||
logger.info(`Version upgrade detected: ${lastVersion} → ${currentVersion}`);
|
||||
await this.createBackup(`upgrade_from_${lastVersion}`);
|
||||
|
||||
// Save current version
|
||||
await this.saveCurrentVersion(currentVersion);
|
||||
|
||||
// Clean up old backups
|
||||
await this.cleanupOldBackups();
|
||||
logger.info("Backup system initialized successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a backup of settings and database
|
||||
*/
|
||||
async createBackup(reason: string = "manual"): Promise<string> {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const version = app.getVersion();
|
||||
const backupName = `v${version}_${timestamp}_${reason}`;
|
||||
const backupPath = path.join(this.backupBasePath, backupName);
|
||||
|
||||
logger.info(`Creating backup: ${backupName} (reason: ${reason})`);
|
||||
|
||||
try {
|
||||
// Create backup directory
|
||||
await fs.mkdir(backupPath, { recursive: true });
|
||||
logger.debug(`Backup directory created: ${backupPath}`);
|
||||
|
||||
// Backup settings file
|
||||
const settingsBackupPath = path.join(
|
||||
backupPath,
|
||||
path.basename(this.settingsFilePath),
|
||||
);
|
||||
const settingsExists = await this.fileExists(this.settingsFilePath);
|
||||
|
||||
if (settingsExists) {
|
||||
await fs.copyFile(this.settingsFilePath, settingsBackupPath);
|
||||
logger.info("Settings backed up successfully");
|
||||
} else {
|
||||
logger.debug("Settings file not found, skipping settings backup");
|
||||
}
|
||||
|
||||
// Backup SQLite database
|
||||
const dbBackupPath = path.join(
|
||||
backupPath,
|
||||
path.basename(this.dbFilePath),
|
||||
);
|
||||
const dbExists = await this.fileExists(this.dbFilePath);
|
||||
|
||||
if (dbExists) {
|
||||
await this.backupSQLiteDatabase(this.dbFilePath, dbBackupPath);
|
||||
logger.info("Database backed up successfully");
|
||||
} else {
|
||||
logger.debug("Database file not found, skipping database backup");
|
||||
}
|
||||
|
||||
// Create backup metadata
|
||||
const metadata: BackupMetadata = {
|
||||
version,
|
||||
timestamp: new Date().toISOString(),
|
||||
reason,
|
||||
files: {
|
||||
settings: settingsExists,
|
||||
database: dbExists,
|
||||
},
|
||||
checksums: {
|
||||
settings: settingsExists
|
||||
? await this.getFileChecksum(settingsBackupPath)
|
||||
: null,
|
||||
database: dbExists ? await this.getFileChecksum(dbBackupPath) : null,
|
||||
},
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(backupPath, "backup.json"),
|
||||
JSON.stringify(metadata, null, 2),
|
||||
);
|
||||
|
||||
logger.info(`Backup created successfully: ${backupName}`);
|
||||
return backupPath;
|
||||
} catch (error) {
|
||||
logger.error("Backup failed:", error);
|
||||
// Clean up failed backup
|
||||
try {
|
||||
await fs.rm(backupPath, { recursive: true, force: true });
|
||||
logger.debug("Failed backup directory cleaned up");
|
||||
} catch (cleanupError) {
|
||||
logger.error("Failed to clean up backup directory:", cleanupError);
|
||||
}
|
||||
throw new Error(`Backup creation failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available backups
|
||||
*/
|
||||
async listBackups(): Promise<BackupInfo[]> {
|
||||
try {
|
||||
const entries = await fs.readdir(this.backupBasePath, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
const backups: BackupInfo[] = [];
|
||||
|
||||
logger.debug(`Found ${entries.length} entries in backup directory`);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const metadataPath = path.join(
|
||||
this.backupBasePath,
|
||||
entry.name,
|
||||
"backup.json",
|
||||
);
|
||||
|
||||
try {
|
||||
const metadataContent = await fs.readFile(metadataPath, "utf8");
|
||||
const metadata: BackupMetadata = JSON.parse(metadataContent);
|
||||
backups.push({
|
||||
name: entry.name,
|
||||
...metadata,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(`Invalid backup found: ${entry.name}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Found ${backups.length} valid backups`);
|
||||
|
||||
// Sort by timestamp, newest first
|
||||
return backups.sort(
|
||||
(a, b) =>
|
||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Failed to list backups:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old backups, keeping only the most recent ones
|
||||
*/
|
||||
async cleanupOldBackups(): Promise<void> {
|
||||
try {
|
||||
const backups = await this.listBackups();
|
||||
|
||||
if (backups.length <= this.maxBackups) {
|
||||
logger.debug(
|
||||
`No cleanup needed - ${backups.length} backups (max: ${this.maxBackups})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the newest backups
|
||||
const backupsToDelete = backups.slice(this.maxBackups);
|
||||
|
||||
logger.info(
|
||||
`Cleaning up ${backupsToDelete.length} old backups (keeping ${this.maxBackups} most recent)`,
|
||||
);
|
||||
|
||||
for (const backup of backupsToDelete) {
|
||||
const backupPath = path.join(this.backupBasePath, backup.name);
|
||||
await fs.rm(backupPath, { recursive: true, force: true });
|
||||
logger.debug(`Deleted old backup: ${backup.name}`);
|
||||
}
|
||||
|
||||
logger.info("Old backup cleanup completed");
|
||||
} catch (error) {
|
||||
logger.error("Failed to clean up old backups:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific backup
|
||||
*/
|
||||
async deleteBackup(backupName: string): Promise<void> {
|
||||
const backupPath = path.join(this.backupBasePath, backupName);
|
||||
|
||||
logger.info(`Deleting backup: ${backupName}`);
|
||||
|
||||
try {
|
||||
await fs.rm(backupPath, { recursive: true, force: true });
|
||||
logger.info(`Deleted backup: ${backupName}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete backup ${backupName}:`, error);
|
||||
throw new Error(`Failed to delete backup: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup size in bytes
|
||||
*/
|
||||
async getBackupSize(backupName: string): Promise<number> {
|
||||
const backupPath = path.join(this.backupBasePath, backupName);
|
||||
logger.debug(`Calculating size for backup: ${backupName}`);
|
||||
|
||||
const size = await this.getDirectorySize(backupPath);
|
||||
logger.debug(`Backup ${backupName} size: ${size} bytes`);
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup SQLite database safely
|
||||
*/
|
||||
private async backupSQLiteDatabase(
|
||||
sourcePath: string,
|
||||
destPath: string,
|
||||
): Promise<void> {
|
||||
logger.debug(`Backing up SQLite database: ${sourcePath} → ${destPath}`);
|
||||
const sourceDb = new Database(sourcePath, {
|
||||
readonly: true,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
try {
|
||||
// This is safe even if other connections are active
|
||||
await sourceDb.backup(destPath);
|
||||
logger.info("Database backup completed successfully");
|
||||
} catch (error) {
|
||||
logger.error("Database backup failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
// Always close the temporary connection
|
||||
sourceDb.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if file exists
|
||||
*/
|
||||
private async fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Calculate file checksum
|
||||
*/
|
||||
private async getFileChecksum(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
const fileBuffer = await fs.readFile(filePath);
|
||||
const hash = crypto.createHash("sha256");
|
||||
hash.update(fileBuffer);
|
||||
const checksum = hash.digest("hex");
|
||||
logger.debug(
|
||||
`Checksum calculated for ${filePath}: ${checksum.substring(0, 8)}...`,
|
||||
);
|
||||
return checksum;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to calculate checksum for ${filePath}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get directory size recursively
|
||||
*/
|
||||
private async getDirectorySize(dirPath: string): Promise<number> {
|
||||
let size = 0;
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
size += await this.getDirectorySize(fullPath);
|
||||
} else {
|
||||
const stats = await fs.stat(fullPath);
|
||||
size += stats.size;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to calculate directory size for ${dirPath}:`, error);
|
||||
}
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get last run version
|
||||
*/
|
||||
private async getLastRunVersion(): Promise<string | null> {
|
||||
try {
|
||||
const versionFile = path.join(this.userDataPath, ".last_version");
|
||||
const version = await fs.readFile(versionFile, "utf8");
|
||||
const trimmedVersion = version.trim();
|
||||
logger.debug(`Last run version retrieved: ${trimmedVersion}`);
|
||||
return trimmedVersion;
|
||||
} catch {
|
||||
logger.debug("No previous version file found");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Save current version
|
||||
*/
|
||||
private async saveCurrentVersion(version: string): Promise<void> {
|
||||
const versionFile = path.join(this.userDataPath, ".last_version");
|
||||
await fs.writeFile(versionFile, version, "utf8");
|
||||
logger.debug(`Current version saved: ${version}`);
|
||||
}
|
||||
}
|
||||
45
src/client_logic/template_hook.ts
Normal file
45
src/client_logic/template_hook.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
|
||||
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:32100",
|
||||
},
|
||||
{
|
||||
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");
|
||||
}
|
||||
96
src/components/AppList.tsx
Normal file
96
src/components/AppList.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { PlusCircle } from "lucide-react";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||
import { useLoadApps } from "@/hooks/useLoadApps";
|
||||
|
||||
export function AppList({ show }: { show?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
const [selectedAppId, setSelectedAppId] = useAtom(selectedAppIdAtom);
|
||||
const setSelectedChatId = useSetAtom(selectedChatIdAtom);
|
||||
const { apps, loading, error } = useLoadApps();
|
||||
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleAppClick = (id: number) => {
|
||||
setSelectedAppId(id);
|
||||
setSelectedChatId(null);
|
||||
navigate({
|
||||
to: "/",
|
||||
search: { appId: id },
|
||||
});
|
||||
};
|
||||
|
||||
const handleNewApp = () => {
|
||||
navigate({ to: "/" });
|
||||
// We'll eventually need a create app workflow
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarGroup className="overflow-y-auto h-[calc(100vh-112px)]">
|
||||
<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>
|
||||
|
||||
{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">
|
||||
{apps.map((app) => (
|
||||
<SidebarMenuItem key={app.id} className="mb-1">
|
||||
<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-full">
|
||||
<span className="truncate">{app.name}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatDistanceToNow(new Date(app.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
)}
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
157
src/components/AppUpgrades.tsx
Normal file
157
src/components/AppUpgrades.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Terminal } from "lucide-react";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AppUpgrade } from "@/ipc/ipc_types";
|
||||
|
||||
export function AppUpgrades({ appId }: { appId: number | null }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: upgrades,
|
||||
isLoading,
|
||||
error: queryError,
|
||||
} = useQuery({
|
||||
queryKey: ["app-upgrades", appId],
|
||||
queryFn: () => {
|
||||
if (!appId) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return IpcClient.getInstance().getAppUpgrades({ appId });
|
||||
},
|
||||
enabled: !!appId,
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: executeUpgrade,
|
||||
isPending: isUpgrading,
|
||||
error: mutationError,
|
||||
variables: upgradingVariables,
|
||||
} = useMutation({
|
||||
mutationFn: (upgradeId: string) => {
|
||||
if (!appId) {
|
||||
throw new Error("appId is not set");
|
||||
}
|
||||
return IpcClient.getInstance().executeAppUpgrade({
|
||||
appId,
|
||||
upgradeId,
|
||||
});
|
||||
},
|
||||
onSuccess: (_, upgradeId) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["app-upgrades", appId] });
|
||||
if (upgradeId === "capacitor") {
|
||||
// Capacitor upgrade is done, so we need to invalidate the Capacitor
|
||||
// query to show the new status.
|
||||
queryClient.invalidateQueries({ queryKey: ["is-capacitor", appId] });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleUpgrade = (upgradeId: string) => {
|
||||
executeUpgrade(upgradeId);
|
||||
};
|
||||
|
||||
if (!appId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-semibold mb-3 text-gray-900 dark:text-gray-100">
|
||||
App Upgrades
|
||||
</h3>
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (queryError) {
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-semibold mb-3 text-gray-900 dark:text-gray-100">
|
||||
App Upgrades
|
||||
</h3>
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error loading upgrades</AlertTitle>
|
||||
<AlertDescription>{queryError.message}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentUpgrades = upgrades?.filter((u) => u.isNeeded) ?? [];
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-semibold mb-3 text-gray-900 dark:text-gray-100">
|
||||
App Upgrades
|
||||
</h3>
|
||||
{currentUpgrades.length === 0 ? (
|
||||
<div
|
||||
data-testid="no-app-upgrades-needed"
|
||||
className="p-4 bg-green-50 border border-green-200 dark:bg-green-900/20 dark:border-green-800/50 rounded-lg text-sm text-green-800 dark:text-green-300"
|
||||
>
|
||||
App is up-to-date and has all Dyad capabilities enabled
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{currentUpgrades.map((upgrade: AppUpgrade) => (
|
||||
<div
|
||||
key={upgrade.id}
|
||||
className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg flex justify-between items-start"
|
||||
>
|
||||
<div className="flex-grow">
|
||||
<h4 className="font-semibold text-gray-800 dark:text-gray-200">
|
||||
{upgrade.title}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{upgrade.description}
|
||||
</p>
|
||||
{mutationError && upgradingVariables === upgrade.id && (
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className="mt-3 dark:bg-destructive/15"
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
<AlertTitle className="dark:text-red-200">
|
||||
Upgrade Failed
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-xs text-red-400 dark:text-red-300">
|
||||
{(mutationError as Error).message}{" "}
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
IpcClient.getInstance().openExternalUrl(
|
||||
upgrade.manualUpgradeUrl ?? "https://dyad.sh/docs",
|
||||
);
|
||||
}}
|
||||
className="underline font-medium hover:dark:text-red-200"
|
||||
>
|
||||
Manual Upgrade Instructions
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleUpgrade(upgrade.id)}
|
||||
disabled={isUpgrading && upgradingVariables === upgrade.id}
|
||||
className="ml-4 flex-shrink-0"
|
||||
size="sm"
|
||||
data-testid={`app-upgrade-${upgrade.id}`}
|
||||
>
|
||||
{isUpgrading && upgradingVariables === upgrade.id ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Upgrade
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/components/AutoApproveSwitch.tsx
Normal file
27
src/components/AutoApproveSwitch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/components/AutoFixProblemsSwitch.tsx
Normal file
30
src/components/AutoFixProblemsSwitch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/components/AutoUpdateSwitch.tsx
Normal file
36
src/components/AutoUpdateSwitch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
258
src/components/CapacitorControls.tsx
Normal file
258
src/components/CapacitorControls.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
src/components/ChatInputControls.tsx
Normal file
27
src/components/ChatInputControls.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ContextFilesPicker } from "./ContextFilesPicker";
|
||||
import { ModelPicker } from "./ModelPicker";
|
||||
import { ProModeSelector } from "./ProModeSelector";
|
||||
import { ChatModeSelector } from "./ChatModeSelector";
|
||||
|
||||
export function ChatInputControls({
|
||||
showContextFilesPicker = false,
|
||||
}: {
|
||||
showContextFilesPicker?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex">
|
||||
<ChatModeSelector />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
278
src/components/ChatList.tsx
Normal file
278
src/components/ChatList.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { PlusCircle, MoreVertical, Trash2, Edit3 } 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";
|
||||
|
||||
export function ChatList({ show }: { show?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom);
|
||||
const [selectedAppId, setSelectedAppId] = 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("");
|
||||
|
||||
// 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;
|
||||
}) => {
|
||||
setSelectedChatId(chatId);
|
||||
setSelectedAppId(appId);
|
||||
navigate({
|
||||
to: "/chat",
|
||||
search: { id: chatId },
|
||||
});
|
||||
};
|
||||
|
||||
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)]">
|
||||
<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>
|
||||
|
||||
{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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
76
src/components/ChatModeSelector.tsx
Normal file
76
src/components/ChatModeSelector.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
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";
|
||||
|
||||
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";
|
||||
default:
|
||||
return "Build";
|
||||
}
|
||||
};
|
||||
|
||||
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>Open mode menu</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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
144
src/components/ChatPanel.tsx
Normal file
144
src/components/ChatPanel.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { chatMessagesAtom, chatStreamCountAtom } 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";
|
||||
|
||||
interface ChatPanelProps {
|
||||
chatId?: number;
|
||||
isPreviewOpen: boolean;
|
||||
onTogglePreview: () => void;
|
||||
}
|
||||
|
||||
export function ChatPanel({
|
||||
chatId,
|
||||
isPreviewOpen,
|
||||
onTogglePreview,
|
||||
}: ChatPanelProps) {
|
||||
const [messages, setMessages] = useAtom(chatMessagesAtom);
|
||||
const [isVersionPaneOpen, setIsVersionPaneOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const streamCount = useAtomValue(chatStreamCountAtom);
|
||||
// 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 userScrollTimeoutRef = useRef<number | null>(null);
|
||||
const lastScrollTopRef = useRef<number>(0);
|
||||
|
||||
const scrollToBottom = (behavior: ScrollBehavior = "smooth") => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior });
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!messagesContainerRef.current) return;
|
||||
|
||||
const container = messagesContainerRef.current;
|
||||
const currentScrollTop = container.scrollTop;
|
||||
|
||||
if (currentScrollTop < lastScrollTopRef.current) {
|
||||
setIsUserScrolling(true);
|
||||
|
||||
if (userScrollTimeoutRef.current) {
|
||||
window.clearTimeout(userScrollTimeoutRef.current);
|
||||
}
|
||||
|
||||
userScrollTimeoutRef.current = window.setTimeout(() => {
|
||||
setIsUserScrolling(false);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
lastScrollTopRef.current = currentScrollTop;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log("streamCount", streamCount);
|
||||
scrollToBottom();
|
||||
}, [streamCount]);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchChatMessages = useCallback(async () => {
|
||||
if (!chatId) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
const chat = await IpcClient.getInstance().getChat(chatId);
|
||||
setMessages(chat.messages);
|
||||
}, [chatId, setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchChatMessages();
|
||||
}, [fetchChatMessages]);
|
||||
|
||||
// Auto-scroll effect when messages change
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isUserScrolling &&
|
||||
messagesContainerRef.current &&
|
||||
messages.length > 0
|
||||
) {
|
||||
const { scrollTop, clientHeight, scrollHeight } =
|
||||
messagesContainerRef.current;
|
||||
const threshold = 280;
|
||||
const isNearBottom =
|
||||
scrollHeight - (scrollTop + clientHeight) <= threshold;
|
||||
|
||||
if (isNearBottom) {
|
||||
requestAnimationFrame(() => {
|
||||
scrollToBottom("instant");
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [messages, isUserScrolling]);
|
||||
|
||||
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">
|
||||
<MessagesList
|
||||
messages={messages}
|
||||
messagesEndRef={messagesEndRef}
|
||||
ref={messagesContainerRef}
|
||||
/>
|
||||
<ChatError error={error} onDismiss={() => setError(null)} />
|
||||
<ChatInput chatId={chatId} />
|
||||
</div>
|
||||
)}
|
||||
<VersionPane
|
||||
isVisible={isVersionPaneOpen}
|
||||
onClose={() => setIsVersionPaneOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
src/components/CommunityCodeConsentDialog.tsx
Normal file
51
src/components/CommunityCodeConsentDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
84
src/components/ConfirmationDialog.tsx
Normal file
84
src/components/ConfirmationDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
412
src/components/ContextFilesPicker.tsx
Normal file
412
src/components/ContextFilesPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
src/components/CreateAppDialog.tsx
Normal file
137
src/components/CreateAppDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
src/components/CreateCustomModelDialog.tsx
Normal file
200
src/components/CreateCustomModelDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
167
src/components/CreateCustomProviderDialog.tsx
Normal file
167
src/components/CreateCustomProviderDialog.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import React, { useState } 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";
|
||||
|
||||
interface CreateCustomProviderDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function CreateCustomProviderDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: CreateCustomProviderDialogProps) {
|
||||
const [id, setId] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [apiBaseUrl, setApiBaseUrl] = useState("");
|
||||
const [envVarName, setEnvVarName] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const { createProvider, isCreating, error } =
|
||||
useCustomLanguageModelProvider();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
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 create custom provider",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isCreating) {
|
||||
setErrorMessage("");
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Custom Provider</DialogTitle>
|
||||
<DialogDescription>
|
||||
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={isCreating}
|
||||
/>
|
||||
<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={isCreating}
|
||||
/>
|
||||
<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={isCreating}
|
||||
/>
|
||||
<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={isCreating}
|
||||
/>
|
||||
<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={isCreating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isCreating}>
|
||||
{isCreating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isCreating ? "Adding..." : "Add Provider"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
235
src/components/CreatePromptDialog.tsx
Normal file
235
src/components/CreatePromptDialog.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export function CreateOrEditPromptDialog({
|
||||
mode,
|
||||
prompt,
|
||||
onCreatePrompt,
|
||||
onUpdatePrompt,
|
||||
trigger,
|
||||
}: CreateOrEditPromptDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
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
|
||||
useEffect(() => {
|
||||
if (mode === "edit" && prompt) {
|
||||
setDraft({
|
||||
title: prompt.title,
|
||||
description: prompt.description || "",
|
||||
content: prompt.content,
|
||||
});
|
||||
} else {
|
||||
setDraft({ title: "", description: "", content: "" });
|
||||
}
|
||||
}, [mode, prompt, 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 {
|
||||
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,
|
||||
}: {
|
||||
onCreatePrompt: (prompt: {
|
||||
title: string;
|
||||
description?: string;
|
||||
content: string;
|
||||
}) => Promise<any>;
|
||||
}) {
|
||||
return (
|
||||
<CreateOrEditPromptDialog mode="create" onCreatePrompt={onCreatePrompt} />
|
||||
);
|
||||
}
|
||||
80
src/components/CustomErrorToast.tsx
Normal file
80
src/components/CustomErrorToast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
src/components/DeleteConfirmationDialog.tsx
Normal file
71
src/components/DeleteConfirmationDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/components/DyadProSuccessDialog.tsx
Normal file
52
src/components/DyadProSuccessDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
223
src/components/EditCustomModelDialog.tsx
Normal file
223
src/components/EditCustomModelDialog.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
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 { 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 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: () => {
|
||||
showSuccess("Custom model updated successfully!");
|
||||
onSuccess();
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
showError(error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
mutation.mutate();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!mutation.isPending) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!model) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[525px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Custom Model</DialogTitle>
|
||||
<DialogDescription>
|
||||
Modify the configuration of the selected language model.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-model-id" className="text-right">
|
||||
Model ID*
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-model-id"
|
||||
value={apiName}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setApiName(e.target.value)
|
||||
}
|
||||
className="col-span-3"
|
||||
placeholder="This must match the model expected by the API"
|
||||
required
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-model-name" className="text-right">
|
||||
Name*
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-model-name"
|
||||
value={displayName}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setDisplayName(e.target.value)
|
||||
}
|
||||
className="col-span-3"
|
||||
placeholder="Human-friendly name for the model"
|
||||
required
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-description" className="text-right">
|
||||
Description
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-description"
|
||||
value={description}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setDescription(e.target.value)
|
||||
}
|
||||
className="col-span-3"
|
||||
placeholder="Optional: Describe the model's capabilities"
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-max-output-tokens" className="text-right">
|
||||
Max Output Tokens
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-max-output-tokens"
|
||||
type="number"
|
||||
value={maxOutputTokens}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMaxOutputTokens(e.target.value)
|
||||
}
|
||||
className="col-span-3"
|
||||
placeholder="Optional: e.g., 4096"
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-context-window" className="text-right">
|
||||
Context Window
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-context-window"
|
||||
type="number"
|
||||
value={contextWindow}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setContextWindow(e.target.value)
|
||||
}
|
||||
className="col-span-3"
|
||||
placeholder="Optional: e.g., 8192"
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Updating..." : "Update Model"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
113
src/components/ErrorBoundary.tsx
Normal file
113
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LightbulbIcon } from "lucide-react";
|
||||
import { ErrorComponentProps } from "@tanstack/react-router";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
|
||||
export function ErrorBoundary({ error }: ErrorComponentProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const posthog = usePostHog();
|
||||
|
||||
useEffect(() => {
|
||||
console.error("An error occurred in the route:", error);
|
||||
posthog.captureException(error);
|
||||
}, [error]);
|
||||
|
||||
const handleReportBug = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Get system debug info
|
||||
const debugInfo = await IpcClient.getInstance().getSystemDebugInfo();
|
||||
|
||||
// Create a formatted issue body with the debug info and error information
|
||||
const issueBody = `
|
||||
## Bug Description
|
||||
<!-- Please describe the issue you're experiencing -->
|
||||
|
||||
## Steps to Reproduce
|
||||
<!-- Please list the steps to reproduce the issue -->
|
||||
|
||||
## Expected Behavior
|
||||
<!-- What did you expect to happen? -->
|
||||
|
||||
## Actual Behavior
|
||||
<!-- What actually happened? -->
|
||||
|
||||
## Error Details
|
||||
- Error Name: ${error?.name || "Unknown"}
|
||||
- Error Message: ${error?.message || "Unknown"}
|
||||
${error?.stack ? `\n\`\`\`\n${error.stack.slice(0, 1000)}\n\`\`\`` : ""}
|
||||
|
||||
## System Information
|
||||
- Dyad Version: ${debugInfo.dyadVersion}
|
||||
- Platform: ${debugInfo.platform}
|
||||
- Architecture: ${debugInfo.architecture}
|
||||
- Node Version: ${debugInfo.nodeVersion || "Not available"}
|
||||
- PNPM Version: ${debugInfo.pnpmVersion || "Not available"}
|
||||
- Node Path: ${debugInfo.nodePath || "Not available"}
|
||||
- Telemetry ID: ${debugInfo.telemetryId || "Not available"}
|
||||
|
||||
## Logs
|
||||
\`\`\`
|
||||
${debugInfo.logs.slice(-3_500) || "No logs available"}
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
// Create the GitHub issue URL with the pre-filled body
|
||||
const encodedBody = encodeURIComponent(issueBody);
|
||||
const encodedTitle = encodeURIComponent(
|
||||
"[bug] Error in Dyad application",
|
||||
);
|
||||
const githubIssueUrl = `https://github.com/dyad-sh/dyad/issues/new?title=${encodedTitle}&labels=bug,filed-from-app,client-error&body=${encodedBody}`;
|
||||
|
||||
// Open the pre-filled GitHub issue page
|
||||
await IpcClient.getInstance().openExternalUrl(githubIssueUrl);
|
||||
} catch (err) {
|
||||
console.error("Failed to prepare bug report:", err);
|
||||
// Fallback to opening the regular GitHub issue page
|
||||
IpcClient.getInstance().openExternalUrl(
|
||||
"https://github.com/dyad-sh/dyad/issues/new",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-screen p-6">
|
||||
<div className="max-w-md w-full bg-background p-6 rounded-lg shadow-lg">
|
||||
<h2 className="text-xl font-bold mb-4">
|
||||
Sorry, that shouldn't have happened!
|
||||
</h2>
|
||||
|
||||
<p className="text-sm mb-3">There was an error loading the app...</p>
|
||||
|
||||
{error && (
|
||||
<div className="bg-slate-100 dark:bg-slate-800 p-4 rounded-md mb-6">
|
||||
<p className="text-sm mb-1">
|
||||
<strong>Error name:</strong> {error.name}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<strong>Error message:</strong> {error.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button onClick={handleReportBug} disabled={isLoading}>
|
||||
{isLoading ? "Preparing report..." : "Report Bug"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md flex items-center gap-2">
|
||||
<LightbulbIcon className="h-4 w-4 text-blue-700 dark:text-blue-400 flex-shrink-0" />
|
||||
<p className="text-sm text-blue-700 dark:text-blue-400">
|
||||
<strong>Tip:</strong> Try closing and re-opening Dyad as a temporary
|
||||
workaround.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
943
src/components/GitHubConnector.tsx
Normal file
943
src/components/GitHubConnector.tsx
Normal file
@@ -0,0 +1,943 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 () => {
|
||||
if (!appId) return;
|
||||
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(() => {
|
||||
if (!appId) return; // Don't set up listeners if appId is null initially
|
||||
|
||||
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);
|
||||
};
|
||||
}, [appId]); // 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 || !appId} // 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
60
src/components/GitHubIntegration.tsx
Normal file
60
src/components/GitHubIntegration.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
244
src/components/HelpBotDialog.tsx
Normal file
244
src/components/HelpBotDialog.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { LoadingBlock, VanillaMarkdownParser } from "@/components/LoadingBlock";
|
||||
|
||||
interface HelpBotDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
reasoning?: string;
|
||||
}
|
||||
|
||||
export function HelpBotDialog({ isOpen, onClose }: HelpBotDialogProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const assistantBufferRef = useRef("");
|
||||
const reasoningBufferRef = useRef("");
|
||||
const flushTimerRef = useRef<number | null>(null);
|
||||
const FLUSH_INTERVAL_MS = 100;
|
||||
|
||||
const sessionId = useMemo(() => uuidv4(), [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// Clean up when dialog closes
|
||||
setMessages([]);
|
||||
setInput("");
|
||||
setError(null);
|
||||
assistantBufferRef.current = "";
|
||||
reasoningBufferRef.current = "";
|
||||
|
||||
// Clear the flush timer
|
||||
if (flushTimerRef.current) {
|
||||
window.clearInterval(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Cleanup on component unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Clear the flush timer on unmount
|
||||
if (flushTimerRef.current) {
|
||||
window.clearInterval(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSend = async () => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || streaming) return;
|
||||
setError(null); // Clear any previous errors
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "user", content: trimmed },
|
||||
{ role: "assistant", content: "", reasoning: "" },
|
||||
]);
|
||||
assistantBufferRef.current = "";
|
||||
reasoningBufferRef.current = "";
|
||||
setInput("");
|
||||
setStreaming(true);
|
||||
|
||||
IpcClient.getInstance().startHelpChat(sessionId, trimmed, {
|
||||
onChunk: (delta) => {
|
||||
// Buffer assistant content; UI will flush on interval for smoothness
|
||||
assistantBufferRef.current += delta;
|
||||
},
|
||||
onEnd: () => {
|
||||
// Final flush then stop streaming
|
||||
setMessages((prev) => {
|
||||
const next = [...prev];
|
||||
const lastIdx = next.length - 1;
|
||||
if (lastIdx >= 0 && next[lastIdx].role === "assistant") {
|
||||
next[lastIdx] = {
|
||||
...next[lastIdx],
|
||||
content: assistantBufferRef.current,
|
||||
reasoning: reasoningBufferRef.current,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setStreaming(false);
|
||||
if (flushTimerRef.current) {
|
||||
window.clearInterval(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
},
|
||||
onError: (errorMessage: string) => {
|
||||
setError(errorMessage);
|
||||
setStreaming(false);
|
||||
|
||||
// Clear the flush timer
|
||||
if (flushTimerRef.current) {
|
||||
window.clearInterval(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Clear the buffers
|
||||
assistantBufferRef.current = "";
|
||||
reasoningBufferRef.current = "";
|
||||
|
||||
// Remove the empty assistant message that was added optimistically
|
||||
setMessages((prev) => {
|
||||
const next = [...prev];
|
||||
if (
|
||||
next.length > 0 &&
|
||||
next[next.length - 1].role === "assistant" &&
|
||||
!next[next.length - 1].content
|
||||
) {
|
||||
next.pop();
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Start smooth flush interval
|
||||
if (flushTimerRef.current) {
|
||||
window.clearInterval(flushTimerRef.current);
|
||||
}
|
||||
flushTimerRef.current = window.setInterval(() => {
|
||||
setMessages((prev) => {
|
||||
const next = [...prev];
|
||||
const lastIdx = next.length - 1;
|
||||
if (lastIdx >= 0 && next[lastIdx].role === "assistant") {
|
||||
const current = next[lastIdx];
|
||||
// Only update if there's any new data to apply
|
||||
if (
|
||||
current.content !== assistantBufferRef.current ||
|
||||
current.reasoning !== reasoningBufferRef.current
|
||||
) {
|
||||
next[lastIdx] = {
|
||||
...current,
|
||||
content: assistantBufferRef.current,
|
||||
reasoning: reasoningBufferRef.current,
|
||||
};
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Dyad Help Bot</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3 h-[480px]">
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border border-destructive/20 rounded-md p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="text-destructive text-sm font-medium">
|
||||
Error:
|
||||
</div>
|
||||
<div className="text-destructive text-sm flex-1">{error}</div>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="text-destructive hover:text-destructive/80 text-xs"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto rounded-md border p-3 bg-(--background-lightest)">
|
||||
{messages.length === 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Ask a question about using Dyad.
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground/70 bg-muted/50 rounded-md p-3">
|
||||
This conversation may be logged and used to improve the
|
||||
product. Please do not put any sensitive information in here.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{messages.map((m, i) => (
|
||||
<div key={i}>
|
||||
{m.role === "user" ? (
|
||||
<div className="text-right">
|
||||
<div className="inline-block rounded-lg px-3 py-2 bg-primary text-primary-foreground">
|
||||
{m.content}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-left">
|
||||
{streaming && i === messages.length - 1 && (
|
||||
<LoadingBlock
|
||||
isStreaming={streaming && i === messages.length - 1}
|
||||
/>
|
||||
)}
|
||||
|
||||
{m.content && (
|
||||
<div className="inline-block rounded-lg px-3 py-2 bg-muted prose dark:prose-invert prose-headings:mb-2 prose-p:my-1 prose-pre:my-0 max-w-none">
|
||||
<VanillaMarkdownParser content={m.content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="flex-1 h-10 rounded-md border bg-background px-3 text-sm"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Type your question..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button onClick={handleSend} disabled={streaming || !input.trim()}>
|
||||
{streaming ? "Sending..." : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
489
src/components/HelpDialog.tsx
Normal file
489
src/components/HelpDialog.tsx
Normal file
@@ -0,0 +1,489 @@
|
||||
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";
|
||||
|
||||
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 selectedChatId = useAtomValue(selectedChatIdAtom);
|
||||
const { settings } = useSettings();
|
||||
|
||||
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? -->
|
||||
|
||||
## 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"}
|
||||
- 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 githubIssueUrl = `https://github.com/dyad-sh/dyad/issues/new?title=${encodedTitle}&labels=bug,filed-from-app&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 with pruning to avoid oversized payloads
|
||||
const MAX_LOG_CHARS = 150_000;
|
||||
const MAX_CODEBASE_CHARS = 200_000;
|
||||
const MAX_MESSAGES = 200; // recent messages
|
||||
const MAX_MESSAGE_CHARS = 8_000; // per message
|
||||
|
||||
const logs = chatLogsData.debugInfo?.logs || "";
|
||||
const prunedLogs = logs.length > MAX_LOG_CHARS
|
||||
? logs.slice(-MAX_LOG_CHARS)
|
||||
: logs;
|
||||
|
||||
const codebase = chatLogsData.codebase || "";
|
||||
const prunedCodebase = codebase.length > MAX_CODEBASE_CHARS
|
||||
? codebase.slice(0, MAX_CODEBASE_CHARS) +
|
||||
`\n\n[Truncated codebase snippet: original length ${codebase.length}]`
|
||||
: codebase;
|
||||
|
||||
const allMessages = chatLogsData.chat?.messages || [];
|
||||
const recentMessages = allMessages.slice(-MAX_MESSAGES).map((m) => ({
|
||||
...m,
|
||||
content:
|
||||
typeof m.content === "string" && m.content.length > MAX_MESSAGE_CHARS
|
||||
? m.content.slice(0, MAX_MESSAGE_CHARS) +
|
||||
`\n\n[Truncated message: original length ${m.content.length}]`
|
||||
: m.content,
|
||||
}));
|
||||
|
||||
const chatLogsJson = {
|
||||
systemInfo: {
|
||||
...chatLogsData.debugInfo,
|
||||
logs: prunedLogs,
|
||||
},
|
||||
chat: {
|
||||
...chatLogsData.chat,
|
||||
messages: recentMessages,
|
||||
},
|
||||
codebaseSnippet: prunedCodebase,
|
||||
};
|
||||
|
||||
// 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}
|
||||
|
||||
## 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 githubIssueUrl = `https://github.com/dyad-sh/dyad/issues/new?title=${encodedTitle}&labels=support&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={handleReportBug}
|
||||
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)}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
27
src/components/ImportAppButton.tsx
Normal file
27
src/components/ImportAppButton.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
331
src/components/ImportAppDialog.tsx
Normal file
331
src/components/ImportAppDialog.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
import { useState } 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 {
|
||||
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";
|
||||
|
||||
interface ImportAppDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
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("pnpm install");
|
||||
const [startCommand, setStartCommand] = useState("pnpm dev");
|
||||
const navigate = useNavigate();
|
||||
const { streamMessage } = useStreamChat({ hasChatId: false });
|
||||
const { refreshApps } = useLoadApps();
|
||||
const setSelectedAppId = useSetAtom(selectedAppIdAtom);
|
||||
|
||||
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:
|
||||
"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.",
|
||||
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("pnpm install");
|
||||
setStartCommand("pnpm dev");
|
||||
};
|
||||
|
||||
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>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import App</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select an existing app folder to import into Dyad.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert className="border-blue-500/20 text-blue-500">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
App import is an experimental feature. If you encounter any issues,
|
||||
please report them using the Help button.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<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-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">Selected folder:</p>
|
||||
<p className="text-sm text-muted-foreground break-all">
|
||||
{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-sm text-yellow-500">
|
||||
An app with this name already exists. Please choose a
|
||||
different name:
|
||||
</p>
|
||||
)}
|
||||
<div className="relative">
|
||||
<Label className="text-sm ml-2 mb-2">App name</Label>
|
||||
<Input
|
||||
value={customAppName}
|
||||
onChange={handleAppNameChange}
|
||||
placeholder="Enter new app name"
|
||||
className="w-full pr-8"
|
||||
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-sm hover:no-underline">
|
||||
Advanced options
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-sm ml-2 mb-2">
|
||||
Install command
|
||||
</Label>
|
||||
<Input
|
||||
value={installCommand}
|
||||
onChange={(e) => setInstallCommand(e.target.value)}
|
||||
placeholder="pnpm install"
|
||||
disabled={importAppMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-sm ml-2 mb-2">Start command</Label>
|
||||
<Input
|
||||
value={startCommand}
|
||||
onChange={(e) => setStartCommand(e.target.value)}
|
||||
placeholder="pnpm dev"
|
||||
disabled={importAppMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
{!commandsValid && (
|
||||
<p className="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>
|
||||
AI_RULES.md lets Dyad know which tech stack to use for
|
||||
editing the app
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<AlertDescription>
|
||||
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-sm text-muted-foreground animate-pulse">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Importing app...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={importAppMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={
|
||||
!selectedPath ||
|
||||
importAppMutation.isPending ||
|
||||
nameExists ||
|
||||
!commandsValid
|
||||
}
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
{importAppMutation.isPending ? <>Importing...</> : "Import"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
89
src/components/InputRequestToast.tsx
Normal file
89
src/components/InputRequestToast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
src/components/LoadingBlock.tsx
Normal file
136
src/components/LoadingBlock.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
|
||||
const customLink = ({
|
||||
node: _node,
|
||||
...props
|
||||
}: {
|
||||
node?: any;
|
||||
[key: string]: any;
|
||||
}) => (
|
||||
<a
|
||||
{...props}
|
||||
onClick={(e) => {
|
||||
const url = props.href;
|
||||
if (url) {
|
||||
e.preventDefault();
|
||||
IpcClient.getInstance().openExternalUrl(url);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export const VanillaMarkdownParser = ({ content }: { content: string }) => {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
a: customLink,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
};
|
||||
|
||||
// Chat loader with human-like typing/deleting of rotating messages
|
||||
function ChatLoader() {
|
||||
const [currentTextIndex, setCurrentTextIndex] = useState(0);
|
||||
const [displayText, setDisplayText] = useState("");
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [typingSpeed, setTypingSpeed] = useState(100);
|
||||
|
||||
const loadingTexts = [
|
||||
"Preparing your conversation... 🗨️",
|
||||
"Gathering thoughts... 💭",
|
||||
"Crafting the perfect response... 🎨",
|
||||
"Almost there... 🚀",
|
||||
"Just a moment... ⏳",
|
||||
"Warming up the neural networks... 🧠",
|
||||
"Connecting the dots... 🔗",
|
||||
"Brewing some digital magic... ✨",
|
||||
"Assembling words with care... 🔤",
|
||||
"Fine-tuning the response... 🎯",
|
||||
"Diving into deep thought... 🤿",
|
||||
"Weaving ideas together... 🕸️",
|
||||
"Sparking up the conversation... ⚡",
|
||||
"Polishing the perfect reply... 💎",
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const currentText = loadingTexts[currentTextIndex];
|
||||
const timer = window.setTimeout(() => {
|
||||
if (!isDeleting) {
|
||||
if (displayText.length < currentText.length) {
|
||||
setDisplayText(currentText.substring(0, displayText.length + 1));
|
||||
const randomSpeed = Math.random() * 50 + 30;
|
||||
const isLongPause = Math.random() > 0.85;
|
||||
setTypingSpeed(isLongPause ? 300 : randomSpeed);
|
||||
} else {
|
||||
setTypingSpeed(1500);
|
||||
setIsDeleting(true);
|
||||
}
|
||||
} else {
|
||||
if (displayText.length > 0) {
|
||||
setDisplayText(currentText.substring(0, displayText.length - 1));
|
||||
setTypingSpeed(30);
|
||||
} else {
|
||||
setIsDeleting(false);
|
||||
setCurrentTextIndex((prev) => (prev + 1) % loadingTexts.length);
|
||||
setTypingSpeed(500);
|
||||
}
|
||||
}
|
||||
}, typingSpeed);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [displayText, isDeleting, currentTextIndex, typingSpeed]);
|
||||
|
||||
const renderFadingText = () => {
|
||||
return displayText.split("").map((char, index) => {
|
||||
const opacity = Math.min(
|
||||
0.8 + (index / (displayText.length || 1)) * 0.2,
|
||||
1,
|
||||
);
|
||||
const isEmoji = /\p{Emoji}/u.test(char);
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
style={{ opacity }}
|
||||
className={isEmoji ? "inline-block animate-emoji-bounce" : ""}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-4">
|
||||
<style>{`
|
||||
@keyframes blink { from, to { opacity: 0 } 50% { opacity: 1 } }
|
||||
@keyframes emoji-bounce { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(-2px) } }
|
||||
@keyframes text-pulse { 0%, 100% { opacity: .85 } 50% { opacity: 1 } }
|
||||
.animate-blink { animation: blink 1s steps(2, start) infinite; }
|
||||
.animate-emoji-bounce { animation: emoji-bounce 1.2s ease-in-out infinite; }
|
||||
.animate-text-pulse { animation: text-pulse 1.8s ease-in-out infinite; }
|
||||
`}</style>
|
||||
<div className="text-center animate-text-pulse">
|
||||
<div className="inline-block">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 font-medium">
|
||||
{renderFadingText()}
|
||||
<span className="ml-1 inline-block w-2 h-4 bg-gray-500 dark:bg-gray-400 animate-blink" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadingBlockProps {
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
// Instead of showing raw thinking content, render the chat loader while streaming.
|
||||
export function LoadingBlock({ isStreaming = false }: LoadingBlockProps) {
|
||||
if (!isStreaming) return null;
|
||||
return <ChatLoader />;
|
||||
}
|
||||
97
src/components/MaxChatTurnsSelector.tsx
Normal file
97
src/components/MaxChatTurnsSelector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
481
src/components/ModelPicker.tsx
Normal file
481
src/components/ModelPicker.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
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";
|
||||
|
||||
export function ModelPicker() {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const onModelSelect = (model: LargeLanguageModel) => {
|
||||
updateSettings({ selectedModel: model });
|
||||
};
|
||||
|
||||
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"]
|
||||
: [];
|
||||
|
||||
// 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 isSmartAutoEnabled =
|
||||
settings.enableProSmartFilesContextMode && isDyadProEnabled(settings);
|
||||
const modelDisplayName = getModelDisplayName();
|
||||
|
||||
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>
|
||||
{isSmartAutoEnabled
|
||||
? "Smart Auto"
|
||||
: model.displayName}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isSmartAutoEnabled && (
|
||||
<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">
|
||||
Dyad Pro
|
||||
</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>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{isSmartAutoEnabled ? (
|
||||
<p>
|
||||
<strong>Smart Auto</strong> uses a cheaper model for
|
||||
easier tasks
|
||||
<br /> and a flagship model for harder tasks
|
||||
</p>
|
||||
) : (
|
||||
model.description
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
{Object.keys(modelsByProviders).length > 1 && (
|
||||
<DropdownMenuSeparator />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Group other providers into submenus */}
|
||||
{Object.entries(modelsByProviders).map(([providerId, models]) => {
|
||||
// Skip auto provider as it's already handled
|
||||
if (providerId === "auto") return null;
|
||||
|
||||
const provider = providers?.find((p) => p.id === providerId);
|
||||
if (models.length === 0) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenuSub key={providerId}>
|
||||
<DropdownMenuSubTrigger className="w-full font-normal">
|
||||
<div className="flex flex-col items-start">
|
||||
<span>{provider?.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{models.length} models
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-56">
|
||||
<DropdownMenuLabel>
|
||||
{provider?.name} 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
157
src/components/NeonConnector.tsx
Normal file
157
src/components/NeonConnector.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
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 } = useDeepLink();
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const handleDeepLink = async () => {
|
||||
if (lastDeepLink?.type === "neon-oauth-return") {
|
||||
await refreshSettings();
|
||||
toast.success("Successfully connected to Neon!");
|
||||
}
|
||||
};
|
||||
handleDeepLink();
|
||||
}, [lastDeepLink]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
38
src/components/NeonDisconnectButton.tsx
Normal file
38
src/components/NeonDisconnectButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/components/NeonIntegration.tsx
Normal file
29
src/components/NeonIntegration.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
src/components/PortalMigrate.tsx
Normal file
110
src/components/PortalMigrate.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink, Database, Loader2 } from "lucide-react";
|
||||
import { showSuccess, showError } from "@/lib/toast";
|
||||
import { useVersions } from "@/hooks/useVersions";
|
||||
|
||||
interface PortalMigrateProps {
|
||||
appId: number;
|
||||
}
|
||||
|
||||
export const PortalMigrate = ({ appId }: PortalMigrateProps) => {
|
||||
const [output, setOutput] = useState<string>("");
|
||||
const { refreshVersions } = useVersions(appId);
|
||||
|
||||
const migrateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
return ipcClient.portalMigrateCreate({ appId });
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
setOutput(result.output);
|
||||
showSuccess(
|
||||
"Database migration file generated and committed successfully!",
|
||||
);
|
||||
refreshVersions();
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
setOutput(`Error: ${errorMessage}`);
|
||||
showError(errorMessage);
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreateMigration = () => {
|
||||
setOutput(""); // Clear previous output
|
||||
migrateMutation.mutate();
|
||||
};
|
||||
|
||||
const openDocs = () => {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
ipcClient.openExternalUrl(
|
||||
"https://www.dyad.sh/docs/templates/portal#create-a-database-migration",
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-primary" />
|
||||
Portal Database Migration
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Generate a new database migration file for your Portal app.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={handleCreateMigration}
|
||||
disabled={migrateMutation.isPending}
|
||||
// className="bg-primary hover:bg-purple-700 text-white"
|
||||
>
|
||||
{migrateMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Database className="w-4 h-4 mr-2" />
|
||||
Generate database migration
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openDocs}
|
||||
className="text-sm"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
Docs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{output && (
|
||||
<div className="mt-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-900 border rounded-lg p-3">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Command Output:
|
||||
</h4>
|
||||
<div className="max-h-64 overflow-auto">
|
||||
<pre className="text-xs text-gray-600 dark:text-gray-400 whitespace-pre-wrap font-mono">
|
||||
{output}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
261
src/components/ProModeSelector.tsx
Normal file
261
src/components/ProModeSelector.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
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 toggleLazyEdits = () => {
|
||||
updateSettings({
|
||||
enableProLazyEditsMode: !settings?.enableProLazyEditsMode,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSmartContextChange = (
|
||||
newValue: "off" | "conservative" | "balanced",
|
||||
) => {
|
||||
if (newValue === "off") {
|
||||
updateSettings({
|
||||
enableProSmartFilesContextMode: false,
|
||||
proSmartContextOption: undefined,
|
||||
});
|
||||
} else if (newValue === "conservative") {
|
||||
updateSettings({
|
||||
enableProSmartFilesContextMode: true,
|
||||
proSmartContextOption: undefined, // Conservative is the default when enabled but no option set
|
||||
});
|
||||
} 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">
|
||||
<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"
|
||||
onClick={() => {
|
||||
IpcClient.getInstance().openExternalUrl(
|
||||
"https://dyad.sh/pro#ai",
|
||||
);
|
||||
}}
|
||||
>
|
||||
Unlock Pro modes
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-5">
|
||||
<SelectorRow
|
||||
id="pro-enabled"
|
||||
label="Enable Dyad Pro"
|
||||
description="Use Dyad Pro AI credits"
|
||||
tooltip="Uses Dyad Pro AI credits for the main AI model and Pro modes."
|
||||
isTogglable={hasProKey}
|
||||
settingEnabled={Boolean(settings?.enableDyadPro)}
|
||||
toggle={toggleProEnabled}
|
||||
/>
|
||||
<SelectorRow
|
||||
id="lazy-edits"
|
||||
label="Turbo Edits"
|
||||
description="Makes file edits faster and cheaper"
|
||||
tooltip="Uses a faster, cheaper model to generate full file updates."
|
||||
isTogglable={proModeTogglable}
|
||||
settingEnabled={Boolean(settings?.enableProLazyEditsMode)}
|
||||
toggle={toggleLazyEdits}
|
||||
/>
|
||||
<SmartContextSelector
|
||||
isTogglable={proModeTogglable}
|
||||
settings={settings}
|
||||
onValueChange={handleSmartContextChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectorRow({
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
tooltip,
|
||||
isTogglable,
|
||||
settingEnabled,
|
||||
toggle,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
tooltip: string;
|
||||
isTogglable: boolean;
|
||||
settingEnabled: boolean;
|
||||
toggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className={!isTogglable ? "text-muted-foreground/50" : ""}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<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>
|
||||
<p
|
||||
className={`text-xs ${!isTogglable ? "text-muted-foreground/50" : "text-muted-foreground"} max-w-55`}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id={id}
|
||||
checked={isTogglable ? settingEnabled : false}
|
||||
onCheckedChange={toggle}
|
||||
disabled={!isTogglable}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SmartContextSelector({
|
||||
isTogglable,
|
||||
settings,
|
||||
onValueChange,
|
||||
}: {
|
||||
isTogglable: boolean;
|
||||
settings: UserSettings | null;
|
||||
onValueChange: (value: "off" | "conservative" | "balanced") => void;
|
||||
}) {
|
||||
// Determine current value based on settings
|
||||
const getCurrentValue = (): "off" | "conservative" | "balanced" => {
|
||||
if (!settings?.enableProSmartFilesContextMode) {
|
||||
return "off";
|
||||
}
|
||||
if (settings?.proSmartContextOption === "balanced") {
|
||||
return "balanced";
|
||||
}
|
||||
// If enabled but no option set (undefined/falsey), it's conservative
|
||||
return "conservative";
|
||||
};
|
||||
|
||||
const currentValue = getCurrentValue();
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className={!isTogglable ? "text-muted-foreground/50" : ""}>
|
||||
Smart Context
|
||||
</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<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">
|
||||
Improve efficiency and save credits working on large codebases.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<p
|
||||
className={`text-xs ${!isTogglable ? "text-muted-foreground/50" : "text-muted-foreground"}`}
|
||||
>
|
||||
Optimizes your AI's code context
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex rounded-md border border-input">
|
||||
<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>
|
||||
<Button
|
||||
variant={currentValue === "conservative" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => onValueChange("conservative")}
|
||||
disabled={!isTogglable}
|
||||
className="rounded-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
|
||||
>
|
||||
Conservative
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentValue === "balanced" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => onValueChange("balanced")}
|
||||
disabled={!isTogglable}
|
||||
className="rounded-l-none h-8 px-3 text-xs flex-shrink-0"
|
||||
>
|
||||
Balanced
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
226
src/components/ProviderSettings.tsx
Normal file
226
src/components/ProviderSettings.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
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, MoreVertical, Trash2 } 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 {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
import { CreateCustomProviderDialog } from "./CreateCustomProviderDialog";
|
||||
|
||||
export function ProviderSettingsGrid() {
|
||||
const navigate = useNavigate();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
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)}
|
||||
>
|
||||
<CardTitle className="text-lg font-medium flex items-center justify-between">
|
||||
{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>
|
||||
|
||||
{isCustom && (
|
||||
<div
|
||||
className="absolute top-2 right-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 hover:bg-muted rounded-full focus:outline-none"
|
||||
data-testid="custom-provider-more-options"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-48 p-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => setProviderToDelete(provider.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Provider
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</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)}
|
||||
onSuccess={() => {
|
||||
setIsDialogOpen(false);
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
76
src/components/ReleaseChannelSelector.tsx
Normal file
76
src/components/ReleaseChannelSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
src/components/SettingsList.tsx
Normal file
88
src/components/SettingsList.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
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: "experiments", label: "Experiments" },
|
||||
{ id: "danger-zone", label: "Danger Zone" },
|
||||
];
|
||||
|
||||
export function SettingsList({ show }: { show: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
const [activeSection, setActiveSection] = useState<string | null>(
|
||||
"general-settings",
|
||||
);
|
||||
|
||||
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 = async (id: string) => {
|
||||
await navigate({
|
||||
to: "/settings",
|
||||
});
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
setActiveSection(id);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
352
src/components/SetupBanner.tsx
Normal file
352
src/components/SetupBanner.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
ChevronRight,
|
||||
GiftIcon,
|
||||
Sparkles,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { providerSettingsRoute } from "@/routes/settings/providers/$provider";
|
||||
import { settingsRoute } from "@/routes/settings";
|
||||
|
||||
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";
|
||||
type NodeInstallStep =
|
||||
| "install"
|
||||
| "waiting-for-continue"
|
||||
| "continue-processing"
|
||||
| "finished-checking";
|
||||
|
||||
export function SetupBanner() {
|
||||
const posthog = usePostHog();
|
||||
const navigate = useNavigate();
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
checkNode();
|
||||
}, [checkNode]);
|
||||
|
||||
const handleAiSetupClick = () => {
|
||||
posthog.capture("setup-flow:ai-provider-setup:google:click");
|
||||
navigate({
|
||||
to: providerSettingsRoute.id,
|
||||
params: { provider: "google" },
|
||||
});
|
||||
};
|
||||
|
||||
const handleOtherProvidersClick = () => {
|
||||
posthog.capture("setup-flow:ai-provider-setup:other:click");
|
||||
navigate({
|
||||
to: settingsRoute.id,
|
||||
});
|
||||
};
|
||||
|
||||
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-6xl 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 text-zinc-700 dark:text-zinc-300 p-4">
|
||||
Follow these steps and you'll be ready to start building with Dyad...
|
||||
</p>
|
||||
<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>
|
||||
)}
|
||||
<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 Model 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-sm mb-3">
|
||||
Connect your preferred AI provider to start generating code.
|
||||
</p>
|
||||
<div
|
||||
className="p-3 bg-blue-50 dark:bg-blue-900/50 border border-blue-200 dark:border-blue-700 rounded-lg cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/70 transition-colors"
|
||||
onClick={handleAiSetupClick}
|
||||
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-blue-100 dark:bg-blue-800 p-1.5 rounded-full">
|
||||
<Sparkles className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-sm text-blue-800 dark:text-blue-300">
|
||||
Setup Google Gemini API Key
|
||||
</h4>
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 flex items-center gap-1">
|
||||
<GiftIcon className="w-3 h-3" />
|
||||
Use Google Gemini for free
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-sm 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, OpenRouter 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;
|
||||
}
|
||||
}
|
||||
226
src/components/SupabaseConnector.tsx
Normal file
226
src/components/SupabaseConnector.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
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 } = useDeepLink();
|
||||
const { isDarkMode } = useTheme();
|
||||
useEffect(() => {
|
||||
const handleDeepLink = async () => {
|
||||
if (lastDeepLink?.type === "supabase-oauth-return") {
|
||||
await refreshSettings();
|
||||
await refreshApp();
|
||||
}
|
||||
};
|
||||
handleDeepLink();
|
||||
}, [lastDeepLink]);
|
||||
const {
|
||||
projects,
|
||||
loading,
|
||||
error,
|
||||
loadProjects,
|
||||
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 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>
|
||||
<Button variant="destructive" onClick={handleUnsetProject}>
|
||||
Disconnect Project
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
103
src/components/SupabaseIntegration.tsx
Normal file
103
src/components/SupabaseIntegration.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/components/TelemetryBanner.tsx
Normal file
75
src/components/TelemetryBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/components/TelemetrySwitch.tsx
Normal file
24
src/components/TelemetrySwitch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
src/components/TemplateCard.tsx
Normal file
163
src/components/TemplateCard.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { useState } from "react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { CommunityCodeConsentDialog } from "./CommunityCodeConsentDialog";
|
||||
import type { Template } from "@/shared/templates";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { showWarning } from "@/lib/toast";
|
||||
|
||||
interface TemplateCardProps {
|
||||
template: Template;
|
||||
isSelected: boolean;
|
||||
onSelect: (templateId: string) => void;
|
||||
onCreateApp: () => void;
|
||||
}
|
||||
|
||||
export const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
template,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onCreateApp,
|
||||
}) => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const [showConsentDialog, setShowConsentDialog] = useState(false);
|
||||
|
||||
const handleCardClick = () => {
|
||||
// If it's a community template and user hasn't accepted community code yet, show dialog
|
||||
if (!template.isOfficial && !settings?.acceptedCommunityCode) {
|
||||
setShowConsentDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (template.requiresNeon && !settings?.neon?.accessToken) {
|
||||
showWarning("Please connect your Neon account to use this template.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, proceed with selection
|
||||
onSelect(template.id);
|
||||
};
|
||||
|
||||
const handleConsentAccept = () => {
|
||||
// Update settings to accept community code
|
||||
updateSettings({ acceptedCommunityCode: true });
|
||||
|
||||
// Select the template
|
||||
onSelect(template.id);
|
||||
|
||||
// Close dialog
|
||||
setShowConsentDialog(false);
|
||||
};
|
||||
|
||||
const handleConsentCancel = () => {
|
||||
// Just close dialog, don't update settings or select template
|
||||
setShowConsentDialog(false);
|
||||
};
|
||||
|
||||
const handleGithubClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (template.githubUrl) {
|
||||
IpcClient.getInstance().openExternalUrl(template.githubUrl);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className={`
|
||||
bg-white dark:bg-gray-800 rounded-xl shadow-sm overflow-hidden
|
||||
transform transition-all duration-300 ease-in-out
|
||||
cursor-pointer group relative
|
||||
${
|
||||
isSelected
|
||||
? "ring-2 ring-blue-500 dark:ring-blue-400 shadow-xl"
|
||||
: "hover:shadow-lg hover:-translate-y-1"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={template.imageUrl}
|
||||
alt={template.title}
|
||||
className={`w-full h-52 object-cover transition-opacity duration-300 group-hover:opacity-80 ${
|
||||
isSelected ? "opacity-75" : ""
|
||||
}`}
|
||||
/>
|
||||
{isSelected && (
|
||||
<span className="absolute top-3 right-3 bg-blue-600 text-white text-xs font-bold px-3 py-1.5 rounded-md shadow-lg">
|
||||
Selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-1.5">
|
||||
<h2
|
||||
className={`text-lg font-semibold ${
|
||||
isSelected
|
||||
? "text-blue-600 dark:text-blue-400"
|
||||
: "text-gray-900 dark:text-white"
|
||||
}`}
|
||||
>
|
||||
{template.title}
|
||||
</h2>
|
||||
{template.isOfficial && !template.isExperimental && (
|
||||
<span
|
||||
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
||||
isSelected
|
||||
? "bg-blue-100 text-blue-700 dark:bg-blue-600 dark:text-blue-100"
|
||||
: "bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-200"
|
||||
}`}
|
||||
>
|
||||
Official
|
||||
</span>
|
||||
)}
|
||||
{template.isExperimental && (
|
||||
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-700 dark:text-yellow-200">
|
||||
Experimental
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3 h-10 overflow-y-auto">
|
||||
{template.description}
|
||||
</p>
|
||||
{template.githubUrl && (
|
||||
<a
|
||||
className={`inline-flex items-center text-sm font-medium transition-colors duration-200 ${
|
||||
isSelected
|
||||
? "text-blue-500 hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-200"
|
||||
: "text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
}`}
|
||||
onClick={handleGithubClick}
|
||||
>
|
||||
View on GitHub{" "}
|
||||
<ArrowLeft className="w-4 h-4 ml-1 transform rotate-180" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCreateApp();
|
||||
}}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold mt-2",
|
||||
settings?.selectedTemplateId !== template.id && "invisible",
|
||||
)}
|
||||
>
|
||||
Create App
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CommunityCodeConsentDialog
|
||||
isOpen={showConsentDialog}
|
||||
onAccept={handleConsentAccept}
|
||||
onCancel={handleConsentCancel}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
80
src/components/ThinkingBudgetSelector.tsx
Normal file
80
src/components/ThinkingBudgetSelector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
659
src/components/VercelConnector.tsx
Normal file
659
src/components/VercelConnector.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
61
src/components/VercelIntegration.tsx
Normal file
61
src/components/VercelIntegration.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
231
src/components/app-sidebar.tsx
Normal file
231
src/components/app-sidebar.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
Home,
|
||||
Inbox,
|
||||
Settings,
|
||||
HelpCircle,
|
||||
Store,
|
||||
BookOpen,
|
||||
} from "lucide-react";
|
||||
import { Link, useRouterState } from "@tanstack/react-router";
|
||||
import { useSidebar } from "@/components/ui/sidebar"; // import useSidebar hook
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { dropdownOpenAtom } from "@/atoms/uiAtoms";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { ChatList } from "./ChatList";
|
||||
import { AppList } from "./AppList";
|
||||
import { HelpDialog } from "./HelpDialog"; // Import the new dialog
|
||||
import { SettingsList } from "./SettingsList";
|
||||
|
||||
// Menu items.
|
||||
const items = [
|
||||
{
|
||||
title: "Apps",
|
||||
to: "/",
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
title: "Chat",
|
||||
to: "/chat",
|
||||
icon: Inbox,
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
to: "/settings",
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
title: "Library",
|
||||
to: "/library",
|
||||
icon: BookOpen,
|
||||
},
|
||||
{
|
||||
title: "Hub",
|
||||
to: "/hub",
|
||||
icon: Store,
|
||||
},
|
||||
];
|
||||
|
||||
// Hover state types
|
||||
type HoverState =
|
||||
| "start-hover:app"
|
||||
| "start-hover:chat"
|
||||
| "start-hover:settings"
|
||||
| "start-hover:library"
|
||||
| "clear-hover"
|
||||
| "no-hover";
|
||||
|
||||
export function AppSidebar() {
|
||||
const { state, toggleSidebar } = useSidebar(); // retrieve current sidebar state
|
||||
const [hoverState, setHoverState] = useState<HoverState>("no-hover");
|
||||
const expandedByHover = useRef(false);
|
||||
const [isHelpDialogOpen, setIsHelpDialogOpen] = useState(false); // State for dialog
|
||||
const [isDropdownOpen] = useAtom(dropdownOpenAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (hoverState.startsWith("start-hover") && state === "collapsed") {
|
||||
expandedByHover.current = true;
|
||||
toggleSidebar();
|
||||
}
|
||||
if (
|
||||
hoverState === "clear-hover" &&
|
||||
state === "expanded" &&
|
||||
expandedByHover.current &&
|
||||
!isDropdownOpen
|
||||
) {
|
||||
toggleSidebar();
|
||||
expandedByHover.current = false;
|
||||
setHoverState("no-hover");
|
||||
}
|
||||
}, [hoverState, toggleSidebar, state, setHoverState, isDropdownOpen]);
|
||||
|
||||
const routerState = useRouterState();
|
||||
const isAppRoute =
|
||||
routerState.location.pathname === "/" ||
|
||||
routerState.location.pathname.startsWith("/app-details");
|
||||
const isChatRoute = routerState.location.pathname === "/chat";
|
||||
const isSettingsRoute = routerState.location.pathname.startsWith("/settings");
|
||||
|
||||
let selectedItem: string | null = null;
|
||||
if (hoverState === "start-hover:app") {
|
||||
selectedItem = "Apps";
|
||||
} else if (hoverState === "start-hover:chat") {
|
||||
selectedItem = "Chat";
|
||||
} else if (hoverState === "start-hover:settings") {
|
||||
selectedItem = "Settings";
|
||||
} else if (hoverState === "start-hover:library") {
|
||||
selectedItem = "Library";
|
||||
} else if (state === "expanded") {
|
||||
if (isAppRoute) {
|
||||
selectedItem = "Apps";
|
||||
} else if (isChatRoute) {
|
||||
selectedItem = "Chat";
|
||||
} else if (isSettingsRoute) {
|
||||
selectedItem = "Settings";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
collapsible="icon"
|
||||
onMouseLeave={() => {
|
||||
if (!isDropdownOpen) {
|
||||
setHoverState("clear-hover");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SidebarContent className="overflow-hidden">
|
||||
<div className="flex mt-8">
|
||||
{/* Left Column: Menu items */}
|
||||
<div className="">
|
||||
<SidebarTrigger
|
||||
onMouseEnter={() => {
|
||||
setHoverState("clear-hover");
|
||||
}}
|
||||
/>
|
||||
<AppIcons onHoverChange={setHoverState} />
|
||||
</div>
|
||||
{/* Right Column: Chat List Section */}
|
||||
<div className="w-[240px]">
|
||||
<AppList show={selectedItem === "Apps"} />
|
||||
<ChatList show={selectedItem === "Chat"} />
|
||||
<SettingsList show={selectedItem === "Settings"} />
|
||||
</div>
|
||||
</div>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
{/* Change button to open dialog instead of linking */}
|
||||
<SidebarMenuButton
|
||||
size="sm"
|
||||
className="font-medium w-14 flex flex-col items-center gap-1 h-14 mb-2 rounded-2xl"
|
||||
onClick={() => setIsHelpDialogOpen(true)} // Open dialog on click
|
||||
>
|
||||
<HelpCircle className="h-5 w-5" />
|
||||
<span className={"text-xs"}>Help</span>
|
||||
</SidebarMenuButton>
|
||||
<HelpDialog
|
||||
isOpen={isHelpDialogOpen}
|
||||
onClose={() => setIsHelpDialogOpen(false)}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
function AppIcons({
|
||||
onHoverChange,
|
||||
}: {
|
||||
onHoverChange: (state: HoverState) => void;
|
||||
}) {
|
||||
const routerState = useRouterState();
|
||||
const pathname = routerState.location.pathname;
|
||||
|
||||
return (
|
||||
// When collapsed: only show the main menu
|
||||
<SidebarGroup className="pr-0">
|
||||
{/* <SidebarGroupLabel>Dyad</SidebarGroupLabel> */}
|
||||
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
const isActive =
|
||||
(item.to === "/" && pathname === "/") ||
|
||||
(item.to !== "/" && pathname.startsWith(item.to));
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
size="sm"
|
||||
className="font-medium w-14"
|
||||
>
|
||||
<Link
|
||||
to={item.to}
|
||||
className={`flex flex-col items-center gap-1 h-14 mb-2 rounded-2xl ${
|
||||
isActive ? "bg-sidebar-accent" : ""
|
||||
}`}
|
||||
onMouseEnter={() => {
|
||||
if (item.title === "Apps") {
|
||||
onHoverChange("start-hover:app");
|
||||
} else if (item.title === "Chat") {
|
||||
onHoverChange("start-hover:chat");
|
||||
} else if (item.title === "Settings") {
|
||||
onHoverChange("start-hover:settings");
|
||||
} else if (item.title === "Library") {
|
||||
onHoverChange("start-hover:library");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<item.icon className="h-5 w-5" />
|
||||
<span className={"text-xs"}>{item.title}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
72
src/components/chat/AttachmentsList.tsx
Normal file
72
src/components/chat/AttachmentsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/components/chat/ChatError.tsx
Normal file
29
src/components/chat/ChatError.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
src/components/chat/ChatErrorBox.tsx
Normal file
171
src/components/chat/ChatErrorBox.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { X } 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">
|
||||
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
|
||||
if (
|
||||
error.includes("Resource has been exhausted") ||
|
||||
error.includes("https://ai.google.dev/gemini-api/docs/rate-limits")
|
||||
) {
|
||||
return (
|
||||
<ChatErrorContainer onDismiss={onDismiss}>
|
||||
{error}
|
||||
<span className="ml-1">
|
||||
<ExternalLink href="https://dyad.sh/pro">
|
||||
Upgrade to Dyad Pro
|
||||
</ExternalLink>
|
||||
</span>{" "}
|
||||
or read the
|
||||
<span className="ml-1">
|
||||
<ExternalLink href="https://dyad.sh/docs/help/ai-rate-limit">
|
||||
Rate limit troubleshooting guide.
|
||||
</ExternalLink>
|
||||
</span>
|
||||
</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">
|
||||
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">
|
||||
Upgrade to Dyad Max
|
||||
</ExternalLink>{" "}
|
||||
and get more AI credits
|
||||
</span>
|
||||
</ChatInfoContainer>
|
||||
);
|
||||
}
|
||||
// This is a very long list of model fallbacks that clutters the error message.
|
||||
if (error.includes("Fallbacks=")) {
|
||||
error = error.split("Fallbacks=")[0];
|
||||
}
|
||||
return <ChatErrorContainer onDismiss={onDismiss}>{error}</ChatErrorContainer>;
|
||||
}
|
||||
|
||||
function ExternalLink({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
className="underline cursor-pointer text-blue-500 hover:text-blue-700"
|
||||
onClick={() => IpcClient.getInstance().openExternalUrl(href)}
|
||||
>
|
||||
{children}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
218
src/components/chat/ChatHeader.tsx
Normal file
218
src/components/chat/ChatHeader.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
PanelRightOpen,
|
||||
History,
|
||||
PlusCircle,
|
||||
GitBranch,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { PanelRightClose } from "lucide-react";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { useVersions } from "@/hooks/useVersions";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "../ui/tooltip";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||
import { useChats } from "@/hooks/useChats";
|
||||
import { showError, showSuccess } from "@/lib/toast";
|
||||
import { useEffect } from "react";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
import { useCurrentBranch } from "@/hooks/useCurrentBranch";
|
||||
import { useCheckoutVersion } from "@/hooks/useCheckoutVersion";
|
||||
import { useRenameBranch } from "@/hooks/useRenameBranch";
|
||||
import { isAnyCheckoutVersionInProgressAtom } from "@/store/appAtoms";
|
||||
import { LoadingBar } from "../ui/LoadingBar";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
isVersionPaneOpen: boolean;
|
||||
isPreviewOpen: boolean;
|
||||
onTogglePreview: () => void;
|
||||
onVersionClick: () => void;
|
||||
}
|
||||
|
||||
export function ChatHeader({
|
||||
isVersionPaneOpen,
|
||||
isPreviewOpen,
|
||||
onTogglePreview,
|
||||
onVersionClick,
|
||||
}: ChatHeaderProps) {
|
||||
const appId = useAtomValue(selectedAppIdAtom);
|
||||
const { versions, loading: versionsLoading } = useVersions(appId);
|
||||
const { navigate } = useRouter();
|
||||
const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom);
|
||||
const { refreshChats } = useChats(appId);
|
||||
const { isStreaming } = useStreamChat();
|
||||
const isAnyCheckoutVersionInProgress = useAtomValue(
|
||||
isAnyCheckoutVersionInProgressAtom,
|
||||
);
|
||||
|
||||
const {
|
||||
branchInfo,
|
||||
isLoading: branchInfoLoading,
|
||||
refetchBranchInfo,
|
||||
} = useCurrentBranch(appId);
|
||||
|
||||
const { checkoutVersion, isCheckingOutVersion } = useCheckoutVersion();
|
||||
const { renameBranch, isRenamingBranch } = useRenameBranch();
|
||||
|
||||
useEffect(() => {
|
||||
if (appId) {
|
||||
refetchBranchInfo();
|
||||
}
|
||||
}, [appId, selectedChatId, isStreaming, refetchBranchInfo]);
|
||||
|
||||
const handleCheckoutMainBranch = async () => {
|
||||
if (!appId) return;
|
||||
await checkoutVersion({ appId, versionId: "main" });
|
||||
};
|
||||
|
||||
const handleRenameMasterToMain = async () => {
|
||||
if (!appId) return;
|
||||
// If this throws, it will automatically show an error toast
|
||||
await renameBranch({ oldBranchName: "master", newBranchName: "main" });
|
||||
|
||||
showSuccess("Master branch renamed to main");
|
||||
};
|
||||
|
||||
const handleNewChat = async () => {
|
||||
if (appId) {
|
||||
try {
|
||||
const chatId = await IpcClient.getInstance().createChat(appId);
|
||||
setSelectedChatId(chatId);
|
||||
navigate({
|
||||
to: "/chat",
|
||||
search: { id: chatId },
|
||||
});
|
||||
await refreshChats();
|
||||
} catch (error) {
|
||||
showError(`Failed to create new chat: ${(error as any).toString()}`);
|
||||
}
|
||||
} else {
|
||||
navigate({ to: "/" });
|
||||
}
|
||||
};
|
||||
|
||||
// REMINDER: KEEP UP TO DATE WITH app_handlers.ts
|
||||
const versionPostfix = versions.length === 100_000 ? `+` : "";
|
||||
|
||||
const isNotMainBranch = branchInfo && branchInfo.branch !== "main";
|
||||
|
||||
const currentBranchName = branchInfo?.branch;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full @container">
|
||||
<LoadingBar isVisible={isAnyCheckoutVersionInProgress} />
|
||||
{/* If the version pane is open, it's expected to not always be on the main branch. */}
|
||||
{isNotMainBranch && !isVersionPaneOpen && (
|
||||
<div className="flex flex-col @sm:flex-row items-center justify-between px-4 py-2 bg-amber-100 dark:bg-amber-900 text-amber-800 dark:text-amber-200">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<GitBranch size={16} />
|
||||
<span>
|
||||
{currentBranchName === "<no-branch>" && (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center gap-1">
|
||||
{isAnyCheckoutVersionInProgress ? (
|
||||
<>
|
||||
<span>
|
||||
Please wait, switching back to latest version...
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>Warning:</strong>
|
||||
<span>You are not on a branch</span>
|
||||
<Info size={14} />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{isAnyCheckoutVersionInProgress
|
||||
? "Version checkout is currently in progress"
|
||||
: "Checkout main branch, otherwise changes will not be saved properly"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
)}
|
||||
{currentBranchName && currentBranchName !== "<no-branch>" && (
|
||||
<span>
|
||||
You are on branch: <strong>{currentBranchName}</strong>.
|
||||
</span>
|
||||
)}
|
||||
{branchInfoLoading && <span>Checking branch...</span>}
|
||||
</span>
|
||||
</div>
|
||||
{currentBranchName === "master" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRenameMasterToMain}
|
||||
disabled={isRenamingBranch || branchInfoLoading}
|
||||
>
|
||||
{isRenamingBranch ? "Renaming..." : "Rename master to main"}
|
||||
</Button>
|
||||
) : isAnyCheckoutVersionInProgress && !isCheckingOutVersion ? null : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCheckoutMainBranch}
|
||||
disabled={isCheckingOutVersion || branchInfoLoading}
|
||||
>
|
||||
{isCheckingOutVersion
|
||||
? "Checking out..."
|
||||
: "Switch to main branch"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Why is this pt-0.5? Because the loading bar is h-1 (it always takes space) and we want the vertical spacing to be consistent.*/}
|
||||
<div className="@container flex items-center justify-between pb-1.5 pt-0.5">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
onClick={handleNewChat}
|
||||
variant="ghost"
|
||||
className="hidden @2xs:flex items-center justify-start gap-2 mx-2 py-3"
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
<span>New Chat</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onVersionClick}
|
||||
variant="ghost"
|
||||
className="hidden @6xs:flex cursor-pointer items-center gap-1 text-sm px-2 py-1 rounded-md"
|
||||
>
|
||||
<History size={16} />
|
||||
{versionsLoading
|
||||
? "..."
|
||||
: `Version ${versions.length}${versionPostfix}`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
data-testid="toggle-preview-panel-button"
|
||||
onClick={onTogglePreview}
|
||||
className="cursor-pointer p-2 hover:bg-(--background-lightest) rounded-md"
|
||||
>
|
||||
{isPreviewOpen ? (
|
||||
<PanelRightClose size={20} />
|
||||
) : (
|
||||
<PanelRightOpen size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
932
src/components/chat/ChatInput.tsx
Normal file
932
src/components/chat/ChatInput.tsx
Normal file
@@ -0,0 +1,932 @@
|
||||
import {
|
||||
StopCircleIcon,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
AlertTriangle,
|
||||
AlertOctagon,
|
||||
FileText,
|
||||
Check,
|
||||
Loader2,
|
||||
Package,
|
||||
FileX,
|
||||
SendToBack,
|
||||
Database,
|
||||
ChevronsUpDown,
|
||||
ChevronsDownUp,
|
||||
ChartColumnIncreasing,
|
||||
SendHorizontalIcon,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import {
|
||||
chatInputValueAtom,
|
||||
chatMessagesAtom,
|
||||
selectedChatIdAtom,
|
||||
} from "@/atoms/chatAtoms";
|
||||
import { atom, useAtom, useSetAtom, useAtomValue } from "jotai";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useProposal } from "@/hooks/useProposal";
|
||||
import {
|
||||
ActionProposal,
|
||||
Proposal,
|
||||
SuggestedAction,
|
||||
FileChange,
|
||||
SqlQuery,
|
||||
} from "@/lib/schemas";
|
||||
import type { Message } from "@/ipc/ipc_types";
|
||||
import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
|
||||
import { useRunApp } from "@/hooks/useRunApp";
|
||||
import { AutoApproveSwitch } from "../AutoApproveSwitch";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { CodeHighlight } from "./CodeHighlight";
|
||||
import { TokenBar } from "./TokenBar";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "../ui/tooltip";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useVersions } from "@/hooks/useVersions";
|
||||
import { useAttachments } from "@/hooks/useAttachments";
|
||||
import { AttachmentsList } from "./AttachmentsList";
|
||||
import { DragDropOverlay } from "./DragDropOverlay";
|
||||
import { FileAttachmentDropdown } from "./FileAttachmentDropdown";
|
||||
import { showError, showExtraFilesToast } from "@/lib/toast";
|
||||
import { ChatInputControls } from "../ChatInputControls";
|
||||
import { ChatErrorBox } from "./ChatErrorBox";
|
||||
import { selectedComponentPreviewAtom } from "@/atoms/previewAtoms";
|
||||
import { SelectedComponentDisplay } from "./SelectedComponentDisplay";
|
||||
import { useCheckProblems } from "@/hooks/useCheckProblems";
|
||||
import { LexicalChatInput } from "./LexicalChatInput";
|
||||
|
||||
const showTokenBarAtom = atom(false);
|
||||
|
||||
export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
const posthog = usePostHog();
|
||||
const [inputValue, setInputValue] = useAtom(chatInputValueAtom);
|
||||
const { settings } = useSettings();
|
||||
const appId = useAtomValue(selectedAppIdAtom);
|
||||
const { refreshVersions } = useVersions(appId);
|
||||
const { streamMessage, isStreaming, setIsStreaming, error, setError } =
|
||||
useStreamChat();
|
||||
const [showError, setShowError] = useState(true);
|
||||
const [isApproving, setIsApproving] = useState(false); // State for approving
|
||||
const [isRejecting, setIsRejecting] = useState(false); // State for rejecting
|
||||
const [, setMessages] = useAtom<Message[]>(chatMessagesAtom);
|
||||
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
|
||||
const [showTokenBar, setShowTokenBar] = useAtom(showTokenBarAtom);
|
||||
const [selectedComponent, setSelectedComponent] = useAtom(
|
||||
selectedComponentPreviewAtom,
|
||||
);
|
||||
const { checkProblems } = useCheckProblems(appId);
|
||||
// Use the attachments hook
|
||||
const {
|
||||
attachments,
|
||||
isDraggingOver,
|
||||
handleFileSelect,
|
||||
removeAttachment,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
clearAttachments,
|
||||
handlePaste,
|
||||
} = useAttachments();
|
||||
|
||||
// Use the hook to fetch the proposal
|
||||
const {
|
||||
proposalResult,
|
||||
isLoading: isProposalLoading,
|
||||
error: proposalError,
|
||||
refreshProposal,
|
||||
} = useProposal(chatId);
|
||||
const { proposal, messageId } = proposalResult ?? {};
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setShowError(true);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const fetchChatMessages = useCallback(async () => {
|
||||
if (!chatId) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
const chat = await IpcClient.getInstance().getChat(chatId);
|
||||
setMessages(chat.messages);
|
||||
}, [chatId, setMessages]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (
|
||||
(!inputValue.trim() && attachments.length === 0) ||
|
||||
isStreaming ||
|
||||
!chatId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentInput = inputValue;
|
||||
setInputValue("");
|
||||
setSelectedComponent(null);
|
||||
|
||||
// Send message with attachments and clear them after sending
|
||||
await streamMessage({
|
||||
prompt: currentInput,
|
||||
chatId,
|
||||
attachments,
|
||||
redo: false,
|
||||
selectedComponent,
|
||||
});
|
||||
clearAttachments();
|
||||
posthog.capture("chat:submit");
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (chatId) {
|
||||
IpcClient.getInstance().cancelChatStream(chatId);
|
||||
}
|
||||
setIsStreaming(false);
|
||||
};
|
||||
|
||||
const dismissError = () => {
|
||||
setShowError(false);
|
||||
};
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (!chatId || !messageId || isApproving || isRejecting || isStreaming)
|
||||
return;
|
||||
console.log(
|
||||
`Approving proposal for chatId: ${chatId}, messageId: ${messageId}`,
|
||||
);
|
||||
setIsApproving(true);
|
||||
posthog.capture("chat:approve");
|
||||
try {
|
||||
const result = await IpcClient.getInstance().approveProposal({
|
||||
chatId,
|
||||
messageId,
|
||||
});
|
||||
if (result.extraFiles) {
|
||||
showExtraFilesToast({
|
||||
files: result.extraFiles,
|
||||
error: result.extraFilesError,
|
||||
posthog,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error approving proposal:", err);
|
||||
setError((err as Error)?.message || "An error occurred while approving");
|
||||
} finally {
|
||||
setIsApproving(false);
|
||||
setIsPreviewOpen(true);
|
||||
refreshVersions();
|
||||
if (settings?.enableAutoFixProblems) {
|
||||
checkProblems();
|
||||
}
|
||||
|
||||
// Keep same as handleReject
|
||||
refreshProposal();
|
||||
fetchChatMessages();
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!chatId || !messageId || isApproving || isRejecting || isStreaming)
|
||||
return;
|
||||
console.log(
|
||||
`Rejecting proposal for chatId: ${chatId}, messageId: ${messageId}`,
|
||||
);
|
||||
setIsRejecting(true);
|
||||
posthog.capture("chat:reject");
|
||||
try {
|
||||
await IpcClient.getInstance().rejectProposal({
|
||||
chatId,
|
||||
messageId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error rejecting proposal:", err);
|
||||
setError((err as Error)?.message || "An error occurred while rejecting");
|
||||
} finally {
|
||||
setIsRejecting(false);
|
||||
|
||||
// Keep same as handleApprove
|
||||
refreshProposal();
|
||||
fetchChatMessages();
|
||||
}
|
||||
};
|
||||
|
||||
if (!settings) {
|
||||
return null; // Or loading state
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && showError && (
|
||||
<ChatErrorBox
|
||||
onDismiss={dismissError}
|
||||
error={error}
|
||||
isDyadProEnabled={settings.enableDyadPro ?? false}
|
||||
/>
|
||||
)}
|
||||
{/* Display loading or error state for proposal */}
|
||||
{isProposalLoading && (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
Loading proposal...
|
||||
</div>
|
||||
)}
|
||||
{proposalError && (
|
||||
<div className="p-4 text-sm text-red-600">
|
||||
Error loading proposal: {proposalError}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4" data-testid="chat-input-container">
|
||||
<div
|
||||
className={`relative flex flex-col border border-border rounded-lg bg-(--background-lighter) shadow-sm ${
|
||||
isDraggingOver ? "ring-2 ring-blue-500 border-blue-500" : ""
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Only render ChatInputActions if proposal is loaded */}
|
||||
{proposal &&
|
||||
proposalResult?.chatId === chatId &&
|
||||
settings.selectedChatMode !== "ask" && (
|
||||
<ChatInputActions
|
||||
proposal={proposal}
|
||||
onApprove={handleApprove}
|
||||
onReject={handleReject}
|
||||
isApprovable={
|
||||
!isProposalLoading &&
|
||||
!!proposal &&
|
||||
!!messageId &&
|
||||
!isApproving &&
|
||||
!isRejecting &&
|
||||
!isStreaming
|
||||
}
|
||||
isApproving={isApproving}
|
||||
isRejecting={isRejecting}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SelectedComponentDisplay />
|
||||
|
||||
{/* Use the AttachmentsList component */}
|
||||
<AttachmentsList
|
||||
attachments={attachments}
|
||||
onRemove={removeAttachment}
|
||||
/>
|
||||
|
||||
{/* Use the DragDropOverlay component */}
|
||||
<DragDropOverlay isDraggingOver={isDraggingOver} />
|
||||
|
||||
<div className="flex items-start space-x-2 ">
|
||||
<LexicalChatInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSubmit={handleSubmit}
|
||||
onPaste={handlePaste}
|
||||
placeholder="Ask Dyad to build..."
|
||||
excludeCurrentApp={true}
|
||||
/>
|
||||
|
||||
{isStreaming ? (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-2 py-2 mt-1 mr-1 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg"
|
||||
title="Cancel generation"
|
||||
>
|
||||
<StopCircleIcon size={20} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!inputValue.trim() && attachments.length === 0}
|
||||
className="px-2 py-2 mt-1 mr-1 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg disabled:opacity-50"
|
||||
title="Send message"
|
||||
>
|
||||
<SendHorizontalIcon size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="pl-2 pr-1 flex items-center justify-between pb-2">
|
||||
<div className="flex items-center">
|
||||
<ChatInputControls showContextFilesPicker={true} />
|
||||
{/* File attachment dropdown */}
|
||||
<FileAttachmentDropdown
|
||||
onFileSelect={handleFileSelect}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => setShowTokenBar(!showTokenBar)}
|
||||
variant="ghost"
|
||||
className={`has-[>svg]:px-2 ${
|
||||
showTokenBar ? "text-purple-500 bg-purple-100" : ""
|
||||
}`}
|
||||
size="sm"
|
||||
>
|
||||
<ChartColumnIncreasing size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{showTokenBar ? "Hide token usage" : "Show token usage"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
{/* TokenBar is only displayed when showTokenBar is true */}
|
||||
{showTokenBar && <TokenBar chatId={chatId} />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SuggestionButton({
|
||||
children,
|
||||
onClick,
|
||||
tooltipText,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
tooltipText: string;
|
||||
}) {
|
||||
const { isStreaming } = useStreamChat();
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
disabled={isStreaming}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipText}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function SummarizeInNewChatButton() {
|
||||
const chatId = useAtomValue(selectedChatIdAtom);
|
||||
const appId = useAtomValue(selectedAppIdAtom);
|
||||
const { streamMessage } = useStreamChat();
|
||||
const navigate = useNavigate();
|
||||
const onClick = async () => {
|
||||
if (!appId) {
|
||||
console.error("No app id found");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const newChatId = await IpcClient.getInstance().createChat(appId);
|
||||
// navigate to new chat
|
||||
await navigate({ to: "/chat", search: { id: newChatId } });
|
||||
await streamMessage({
|
||||
prompt: "Summarize from chat-id=" + chatId,
|
||||
chatId: newChatId,
|
||||
});
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<SuggestionButton
|
||||
onClick={onClick}
|
||||
tooltipText="Creating a new chat makes the AI more focused and efficient"
|
||||
>
|
||||
Summarize to new chat
|
||||
</SuggestionButton>
|
||||
);
|
||||
}
|
||||
|
||||
function RefactorFileButton({ path }: { path: string }) {
|
||||
const chatId = useAtomValue(selectedChatIdAtom);
|
||||
const { streamMessage } = useStreamChat();
|
||||
const onClick = () => {
|
||||
if (!chatId) {
|
||||
console.error("No chat id found");
|
||||
return;
|
||||
}
|
||||
streamMessage({
|
||||
prompt: `Refactor ${path} and make it more modular`,
|
||||
chatId,
|
||||
redo: false,
|
||||
});
|
||||
};
|
||||
return (
|
||||
<SuggestionButton
|
||||
onClick={onClick}
|
||||
tooltipText="Refactor the file to improve maintainability"
|
||||
>
|
||||
<span className="max-w-[180px] overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
Refactor {path.split("/").slice(-2).join("/")}
|
||||
</span>
|
||||
</SuggestionButton>
|
||||
);
|
||||
}
|
||||
|
||||
function WriteCodeProperlyButton() {
|
||||
const chatId = useAtomValue(selectedChatIdAtom);
|
||||
const { streamMessage } = useStreamChat();
|
||||
const onClick = () => {
|
||||
if (!chatId) {
|
||||
console.error("No chat id found");
|
||||
return;
|
||||
}
|
||||
streamMessage({
|
||||
prompt: `Write the code in the previous message in the correct format using \`<dyad-write>\` tags!`,
|
||||
chatId,
|
||||
redo: false,
|
||||
});
|
||||
};
|
||||
return (
|
||||
<SuggestionButton
|
||||
onClick={onClick}
|
||||
tooltipText="Write code properly (useful when AI generates the code in the wrong format)"
|
||||
>
|
||||
Write code properly
|
||||
</SuggestionButton>
|
||||
);
|
||||
}
|
||||
|
||||
function RebuildButton() {
|
||||
const { restartApp } = useRunApp();
|
||||
const posthog = usePostHog();
|
||||
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
||||
|
||||
const onClick = useCallback(async () => {
|
||||
if (!selectedAppId) return;
|
||||
|
||||
posthog.capture("action:rebuild");
|
||||
await restartApp({ removeNodeModules: true });
|
||||
}, [selectedAppId, posthog, restartApp]);
|
||||
|
||||
return (
|
||||
<SuggestionButton onClick={onClick} tooltipText="Rebuild the application">
|
||||
Rebuild app
|
||||
</SuggestionButton>
|
||||
);
|
||||
}
|
||||
|
||||
function RestartButton() {
|
||||
const { restartApp } = useRunApp();
|
||||
const posthog = usePostHog();
|
||||
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
||||
|
||||
const onClick = useCallback(async () => {
|
||||
if (!selectedAppId) return;
|
||||
|
||||
posthog.capture("action:restart");
|
||||
await restartApp();
|
||||
}, [selectedAppId, posthog, restartApp]);
|
||||
|
||||
return (
|
||||
<SuggestionButton
|
||||
onClick={onClick}
|
||||
tooltipText="Restart the development server"
|
||||
>
|
||||
Restart app
|
||||
</SuggestionButton>
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshButton() {
|
||||
const { refreshAppIframe } = useRunApp();
|
||||
const posthog = usePostHog();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
posthog.capture("action:refresh");
|
||||
refreshAppIframe();
|
||||
}, [posthog, refreshAppIframe]);
|
||||
|
||||
return (
|
||||
<SuggestionButton
|
||||
onClick={onClick}
|
||||
tooltipText="Refresh the application preview"
|
||||
>
|
||||
Refresh app
|
||||
</SuggestionButton>
|
||||
);
|
||||
}
|
||||
|
||||
function KeepGoingButton() {
|
||||
const { streamMessage } = useStreamChat();
|
||||
const chatId = useAtomValue(selectedChatIdAtom);
|
||||
const onClick = () => {
|
||||
if (!chatId) {
|
||||
console.error("No chat id found");
|
||||
return;
|
||||
}
|
||||
streamMessage({
|
||||
prompt: "Keep going",
|
||||
chatId,
|
||||
});
|
||||
};
|
||||
return (
|
||||
<SuggestionButton onClick={onClick} tooltipText="Keep going">
|
||||
Keep going
|
||||
</SuggestionButton>
|
||||
);
|
||||
}
|
||||
|
||||
function mapActionToButton(action: SuggestedAction) {
|
||||
switch (action.id) {
|
||||
case "summarize-in-new-chat":
|
||||
return <SummarizeInNewChatButton />;
|
||||
case "refactor-file":
|
||||
return <RefactorFileButton path={action.path} />;
|
||||
case "write-code-properly":
|
||||
return <WriteCodeProperlyButton />;
|
||||
case "rebuild":
|
||||
return <RebuildButton />;
|
||||
case "restart":
|
||||
return <RestartButton />;
|
||||
case "refresh":
|
||||
return <RefreshButton />;
|
||||
case "keep-going":
|
||||
return <KeepGoingButton />;
|
||||
default:
|
||||
console.error(`Unsupported action: ${action.id}`);
|
||||
return (
|
||||
<Button variant="outline" size="sm" disabled key={action.id}>
|
||||
Unsupported: {action.id}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ActionProposalActions({ proposal }: { proposal: ActionProposal }) {
|
||||
return (
|
||||
<div className="border-b border-border p-2 pb-0 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 overflow-x-auto pb-2">
|
||||
{proposal.actions.map((action) => mapActionToButton(action))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChatInputActionsProps {
|
||||
proposal: Proposal;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
isApprovable: boolean; // Can be used to enable/disable buttons
|
||||
isApproving: boolean; // State for approving
|
||||
isRejecting: boolean; // State for rejecting
|
||||
}
|
||||
|
||||
// Update ChatInputActions to accept props
|
||||
function ChatInputActions({
|
||||
proposal,
|
||||
onApprove,
|
||||
onReject,
|
||||
isApprovable,
|
||||
isApproving,
|
||||
isRejecting,
|
||||
}: ChatInputActionsProps) {
|
||||
const [isDetailsVisible, setIsDetailsVisible] = useState(false);
|
||||
|
||||
if (proposal.type === "tip-proposal") {
|
||||
return <div>Tip proposal</div>;
|
||||
}
|
||||
if (proposal.type === "action-proposal") {
|
||||
return <ActionProposalActions proposal={proposal}></ActionProposalActions>;
|
||||
}
|
||||
|
||||
// Split files into server functions and other files - only for CodeProposal
|
||||
const serverFunctions =
|
||||
proposal.filesChanged?.filter((f: FileChange) => f.isServerFunction) ?? [];
|
||||
const otherFilesChanged =
|
||||
proposal.filesChanged?.filter((f: FileChange) => !f.isServerFunction) ?? [];
|
||||
|
||||
function formatTitle({
|
||||
title,
|
||||
isDetailsVisible,
|
||||
}: {
|
||||
title: string;
|
||||
isDetailsVisible: boolean;
|
||||
}) {
|
||||
if (isDetailsVisible) {
|
||||
return title;
|
||||
}
|
||||
return title.slice(0, 60) + "...";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<div className="p-2">
|
||||
{/* Row 1: Title, Expand Icon, and Security Chip */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<button
|
||||
className="flex flex-col text-left text-sm hover:bg-muted p-1 rounded justify-start w-full"
|
||||
onClick={() => setIsDetailsVisible(!isDetailsVisible)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{isDetailsVisible ? (
|
||||
<ChevronUp size={16} className="mr-1 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronDown size={16} className="mr-1 flex-shrink-0" />
|
||||
)}
|
||||
<span className="font-medium">
|
||||
{formatTitle({ title: proposal.title, isDetailsVisible })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground ml-6">
|
||||
<ProposalSummary
|
||||
sqlQueries={proposal.sqlQueries}
|
||||
serverFunctions={serverFunctions}
|
||||
packagesAdded={proposal.packagesAdded}
|
||||
filesChanged={otherFilesChanged}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
{proposal.securityRisks.length > 0 && (
|
||||
<span className="bg-red-100 text-red-700 text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0">
|
||||
Security risks found
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: Buttons and Toggle */}
|
||||
<div className="flex items-center justify-start space-x-2">
|
||||
<Button
|
||||
className="px-8"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onApprove}
|
||||
disabled={!isApprovable || isApproving || isRejecting}
|
||||
data-testid="approve-proposal-button"
|
||||
>
|
||||
{isApproving ? (
|
||||
<Loader2 size={16} className="mr-1 animate-spin" />
|
||||
) : (
|
||||
<Check size={16} className="mr-1" />
|
||||
)}
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
className="px-8"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onReject}
|
||||
disabled={!isApprovable || isApproving || isRejecting}
|
||||
data-testid="reject-proposal-button"
|
||||
>
|
||||
{isRejecting ? (
|
||||
<Loader2 size={16} className="mr-1 animate-spin" />
|
||||
) : (
|
||||
<X size={16} className="mr-1" />
|
||||
)}
|
||||
Reject
|
||||
</Button>
|
||||
<div className="flex items-center space-x-1 ml-auto">
|
||||
<AutoApproveSwitch />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-[calc(100vh-300px)]">
|
||||
{isDetailsVisible && (
|
||||
<div className="p-3 border-t border-border bg-muted/50 text-sm">
|
||||
{!!proposal.securityRisks.length && (
|
||||
<div className="mb-3">
|
||||
<h4 className="font-semibold mb-1">Security Risks</h4>
|
||||
<ul className="space-y-1">
|
||||
{proposal.securityRisks.map((risk, index) => (
|
||||
<li key={index} className="flex items-start space-x-2">
|
||||
{risk.type === "warning" ? (
|
||||
<AlertTriangle
|
||||
size={16}
|
||||
className="text-yellow-500 mt-0.5 flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<AlertOctagon
|
||||
size={16}
|
||||
className="text-red-500 mt-0.5 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium">{risk.title}:</span>{" "}
|
||||
<span>{risk.description}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{proposal.sqlQueries?.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<h4 className="font-semibold mb-1">SQL Queries</h4>
|
||||
<ul className="space-y-2">
|
||||
{proposal.sqlQueries.map((query, index) => (
|
||||
<SqlQueryItem key={index} query={query} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{proposal.packagesAdded?.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<h4 className="font-semibold mb-1">Packages Added</h4>
|
||||
<ul className="space-y-1">
|
||||
{proposal.packagesAdded.map((pkg, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="flex items-center space-x-2"
|
||||
onClick={() => {
|
||||
IpcClient.getInstance().openExternalUrl(
|
||||
`https://www.npmjs.com/package/${pkg}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Package
|
||||
size={16}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<span className="cursor-pointer text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
{pkg}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{serverFunctions.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<h4 className="font-semibold mb-1">Server Functions Changed</h4>
|
||||
<ul className="space-y-1">
|
||||
{serverFunctions.map((file: FileChange, index: number) => (
|
||||
<li key={index} className="flex items-center space-x-2">
|
||||
{getIconForFileChange(file)}
|
||||
<span
|
||||
title={file.path}
|
||||
className="truncate cursor-default"
|
||||
>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs truncate">
|
||||
- {file.summary}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{otherFilesChanged.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-semibold mb-1">Files Changed</h4>
|
||||
<ul className="space-y-1">
|
||||
{otherFilesChanged.map((file: FileChange, index: number) => (
|
||||
<li key={index} className="flex items-center space-x-2">
|
||||
{getIconForFileChange(file)}
|
||||
<span
|
||||
title={file.path}
|
||||
className="truncate cursor-default"
|
||||
>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs truncate">
|
||||
- {file.summary}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getIconForFileChange(file: FileChange) {
|
||||
switch (file.type) {
|
||||
case "write":
|
||||
return (
|
||||
<FileText size={16} className="text-muted-foreground flex-shrink-0" />
|
||||
);
|
||||
case "rename":
|
||||
return (
|
||||
<SendToBack size={16} className="text-muted-foreground flex-shrink-0" />
|
||||
);
|
||||
case "delete":
|
||||
return (
|
||||
<FileX size={16} className="text-muted-foreground flex-shrink-0" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Proposal summary component to show counts of changes
|
||||
function ProposalSummary({
|
||||
sqlQueries = [],
|
||||
serverFunctions = [],
|
||||
packagesAdded = [],
|
||||
filesChanged = [],
|
||||
}: {
|
||||
sqlQueries?: Array<SqlQuery>;
|
||||
serverFunctions?: FileChange[];
|
||||
packagesAdded?: string[];
|
||||
filesChanged?: FileChange[];
|
||||
}) {
|
||||
// If no changes, show a simple message
|
||||
if (
|
||||
!sqlQueries.length &&
|
||||
!serverFunctions.length &&
|
||||
!packagesAdded.length &&
|
||||
!filesChanged.length
|
||||
) {
|
||||
return <span>No changes</span>;
|
||||
}
|
||||
|
||||
// Build parts array with only the segments that have content
|
||||
const parts: string[] = [];
|
||||
|
||||
if (sqlQueries.length) {
|
||||
parts.push(
|
||||
`${sqlQueries.length} SQL ${
|
||||
sqlQueries.length === 1 ? "query" : "queries"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (serverFunctions.length) {
|
||||
parts.push(
|
||||
`${serverFunctions.length} Server ${
|
||||
serverFunctions.length === 1 ? "Function" : "Functions"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (packagesAdded.length) {
|
||||
parts.push(
|
||||
`${packagesAdded.length} ${
|
||||
packagesAdded.length === 1 ? "package" : "packages"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (filesChanged.length) {
|
||||
parts.push(
|
||||
`${filesChanged.length} ${filesChanged.length === 1 ? "file" : "files"}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Join all parts with separator
|
||||
return <span>{parts.join(" | ")}</span>;
|
||||
}
|
||||
|
||||
// SQL Query item with expandable functionality
|
||||
function SqlQueryItem({ query }: { query: SqlQuery }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const queryContent = query.content;
|
||||
const queryDescription = query.description;
|
||||
|
||||
return (
|
||||
<li
|
||||
className="bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-3 py-2 border border-border cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database size={16} className="text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-sm font-medium">
|
||||
{queryDescription || "SQL Query"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{isExpanded ? (
|
||||
<ChevronsDownUp size={18} className="text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronsUpDown size={18} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="mt-2 text-xs max-h-[200px] overflow-auto">
|
||||
<CodeHighlight className="language-sql ">
|
||||
{queryContent}
|
||||
</CodeHighlight>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
170
src/components/chat/ChatMessage.tsx
Normal file
170
src/components/chat/ChatMessage.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import type { Message } from "@/ipc/ipc_types";
|
||||
import {
|
||||
DyadMarkdownParser,
|
||||
VanillaMarkdownParser,
|
||||
} from "./DyadMarkdownParser";
|
||||
import { motion } from "framer-motion";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
import { CheckCircle, XCircle, Clock, GitCommit } from "lucide-react";
|
||||
import { formatDistanceToNow, format } from "date-fns";
|
||||
import { useVersions } from "@/hooks/useVersions";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: Message;
|
||||
isLastMessage: boolean;
|
||||
}
|
||||
|
||||
const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => {
|
||||
const { isStreaming } = useStreamChat();
|
||||
const appId = useAtomValue(selectedAppIdAtom);
|
||||
const { versions: liveVersions } = useVersions(appId);
|
||||
// Find the version that was active when this message was sent
|
||||
const messageVersion = useMemo(() => {
|
||||
if (
|
||||
message.role === "assistant" &&
|
||||
message.commitHash &&
|
||||
liveVersions.length
|
||||
) {
|
||||
return (
|
||||
liveVersions.find(
|
||||
(version) =>
|
||||
message.commitHash &&
|
||||
version.oid.slice(0, 7) === message.commitHash.slice(0, 7),
|
||||
) || null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}, [message.commitHash, message.role, liveVersions]);
|
||||
|
||||
// Format the message timestamp
|
||||
const formatTimestamp = (timestamp: string | Date) => {
|
||||
const now = new Date();
|
||||
const messageTime = new Date(timestamp);
|
||||
const diffInHours =
|
||||
(now.getTime() - messageTime.getTime()) / (1000 * 60 * 60);
|
||||
if (diffInHours < 24) {
|
||||
return formatDistanceToNow(messageTime, { addSuffix: true });
|
||||
} else {
|
||||
return format(messageTime, "MMM d, yyyy 'at' h:mm a");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex ${
|
||||
message.role === "assistant" ? "justify-start" : "justify-end"
|
||||
}`}
|
||||
>
|
||||
<div className={`mt-2 w-full max-w-3xl mx-auto group`}>
|
||||
<div
|
||||
className={`rounded-lg p-2 ${
|
||||
message.role === "assistant" ? "" : "ml-24 bg-(--sidebar-accent)"
|
||||
}`}
|
||||
>
|
||||
{message.role === "assistant" &&
|
||||
!message.content &&
|
||||
isStreaming &&
|
||||
isLastMessage ? (
|
||||
<div className="flex h-6 items-center space-x-2 p-2">
|
||||
<motion.div
|
||||
className="h-3 w-3 rounded-full bg-(--primary) dark:bg-blue-500"
|
||||
animate={{ y: [0, -12, 0] }}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration: 0.4,
|
||||
ease: "easeOut",
|
||||
repeatDelay: 1.2,
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className="h-3 w-3 rounded-full bg-(--primary) dark:bg-blue-500"
|
||||
animate={{ y: [0, -12, 0] }}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration: 0.4,
|
||||
ease: "easeOut",
|
||||
delay: 0.4,
|
||||
repeatDelay: 1.2,
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className="h-3 w-3 rounded-full bg-(--primary) dark:bg-blue-500"
|
||||
animate={{ y: [0, -12, 0] }}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration: 0.4,
|
||||
ease: "easeOut",
|
||||
delay: 0.8,
|
||||
repeatDelay: 1.2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="prose dark:prose-invert prose-headings:mb-2 prose-p:my-1 prose-pre:my-0 max-w-none break-words"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{message.role === "assistant" ? (
|
||||
<>
|
||||
<DyadMarkdownParser content={message.content} />
|
||||
{isLastMessage && isStreaming && (
|
||||
<div className="mt-4 ml-4 relative w-5 h-5 animate-spin">
|
||||
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 w-2 h-2 bg-(--primary) dark:bg-blue-500 rounded-full"></div>
|
||||
<div className="absolute bottom-0 left-0 w-2 h-2 bg-(--primary) dark:bg-blue-500 rounded-full opacity-80"></div>
|
||||
<div className="absolute bottom-0 right-0 w-2 h-2 bg-(--primary) dark:bg-blue-500 rounded-full opacity-60"></div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<VanillaMarkdownParser content={message.content} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{message.approvalState && (
|
||||
<div className="mt-2 flex items-center justify-end space-x-1 text-xs">
|
||||
{message.approvalState === "approved" ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<span>Approved</span>
|
||||
</>
|
||||
) : message.approvalState === "rejected" ? (
|
||||
<>
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
<span>Rejected</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Timestamp and commit info for assistant messages - only visible on hover */}
|
||||
{message.role === "assistant" && message.createdAt && (
|
||||
<div className="mt-1 flex items-center justify-start space-x-2 text-xs text-gray-500 dark:text-gray-400 ">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatTimestamp(message.createdAt)}</span>
|
||||
</div>
|
||||
{messageVersion && messageVersion.message && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<GitCommit className="h-3 w-3" />
|
||||
{messageVersion && messageVersion.message && (
|
||||
<span className="max-w-70 truncate font-medium">
|
||||
{
|
||||
messageVersion.message
|
||||
.replace(/^\[dyad\]\s*/i, "")
|
||||
.split("\n")[0]
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessage;
|
||||
91
src/components/chat/CodeHighlight.tsx
Normal file
91
src/components/chat/CodeHighlight.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
memo,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { isInlineCode, useShikiHighlighter } from "react-shiki";
|
||||
import github from "@shikijs/themes/github-light-default";
|
||||
import githubDark from "@shikijs/themes/github-dark-default";
|
||||
import type { Element as HastElement } from "hast";
|
||||
import { useTheme } from "../../contexts/ThemeContext";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
|
||||
interface CodeHighlightProps {
|
||||
className?: string | undefined;
|
||||
children?: ReactNode | undefined;
|
||||
node?: HastElement | undefined;
|
||||
}
|
||||
|
||||
export const CodeHighlight = memo(
|
||||
({ className, children, node, ...props }: CodeHighlightProps) => {
|
||||
const code = String(children).trim();
|
||||
const language = className?.match(/language-(\w+)/)?.[1];
|
||||
const isInline = node ? isInlineCode(node) : false;
|
||||
//handle copying code to clipboard with transition effect
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000); // revert after 2s
|
||||
};
|
||||
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
// Cache for the highlighted code
|
||||
const highlightedCodeCache = useRef<ReactNode | null>(null);
|
||||
|
||||
// Only update the highlighted code if the inputs change
|
||||
const highlightedCode = useShikiHighlighter(
|
||||
code,
|
||||
language,
|
||||
isDarkMode ? githubDark : github,
|
||||
{
|
||||
delay: 150,
|
||||
},
|
||||
);
|
||||
|
||||
// Update the cache whenever we get a new highlighted code
|
||||
useEffect(() => {
|
||||
if (highlightedCode) {
|
||||
highlightedCodeCache.current = highlightedCode;
|
||||
}
|
||||
}, [highlightedCode]);
|
||||
|
||||
// Use the cached version during transitions to prevent flickering
|
||||
const displayedCode = highlightedCode || highlightedCodeCache.current;
|
||||
return !isInline ? (
|
||||
<div
|
||||
className="shiki not-prose relative [&_pre]:overflow-auto
|
||||
[&_pre]:rounded-lg [&_pre]:px-6 [&_pre]:py-7"
|
||||
>
|
||||
{language ? (
|
||||
<div className="absolute top-2 left-2 right-2 text-xs flex justify-between">
|
||||
<span className="tracking-tighter text-muted-foreground/85">
|
||||
{language}
|
||||
</span>
|
||||
{code && (
|
||||
<button
|
||||
className="mr-2 flex items-center text-xs cursor-pointer"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
<span className="ml-1">{copied ? "Copied" : "Copy"}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{displayedCode}
|
||||
</div>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return prevProps.children === nextProps.children;
|
||||
},
|
||||
);
|
||||
52
src/components/chat/DeleteChatDialog.tsx
Normal file
52
src/components/chat/DeleteChatDialog.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
interface DeleteChatDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirmDelete: () => void;
|
||||
chatTitle?: string;
|
||||
}
|
||||
|
||||
export function DeleteChatDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onConfirmDelete,
|
||||
chatTitle,
|
||||
}: DeleteChatDialogProps) {
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Chat</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{chatTitle || "this chat"}"? This
|
||||
action cannot be undone and all messages in this chat will be
|
||||
permanently lost.
|
||||
<br />
|
||||
<br />
|
||||
<strong>Note:</strong> Any code changes that have already been
|
||||
accepted will be kept.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirmDelete}
|
||||
className="bg-red-600 text-white hover:bg-red-700 dark:bg-red-600 dark:text-white dark:hover:bg-red-700"
|
||||
>
|
||||
Delete Chat
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
18
src/components/chat/DragDropOverlay.tsx
Normal file
18
src/components/chat/DragDropOverlay.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Paperclip } from "lucide-react";
|
||||
|
||||
interface DragDropOverlayProps {
|
||||
isDraggingOver: boolean;
|
||||
}
|
||||
|
||||
export function DragDropOverlay({ isDraggingOver }: DragDropOverlayProps) {
|
||||
if (!isDraggingOver) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 bg-blue-100/30 dark:bg-blue-900/30 flex items-center justify-center rounded-lg z-10 pointer-events-none">
|
||||
<div className="bg-background p-4 rounded-lg shadow-lg text-center">
|
||||
<Paperclip className="mx-auto mb-2 text-blue-500" />
|
||||
<p className="text-sm font-medium">Drop files to attach</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
src/components/chat/DyadAddDependency.tsx
Normal file
93
src/components/chat/DyadAddDependency.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import type React from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { IpcClient } from "../../ipc/ipc_client";
|
||||
|
||||
import { Package, ChevronsUpDown, ChevronsDownUp } from "lucide-react";
|
||||
import { CodeHighlight } from "./CodeHighlight";
|
||||
|
||||
interface DyadAddDependencyProps {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
packages?: string;
|
||||
}
|
||||
|
||||
export const DyadAddDependency: React.FC<DyadAddDependencyProps> = ({
|
||||
children,
|
||||
node,
|
||||
}) => {
|
||||
// Extract package attribute from the node if available
|
||||
const packages = node?.properties?.packages?.split(" ") || "";
|
||||
const [isContentVisible, setIsContentVisible] = useState(false);
|
||||
const hasChildren = !!children;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-(--background-lightest) dark:bg-gray-900 hover:bg-(--background-lighter) rounded-lg px-4 py-3 border my-2 border-border ${
|
||||
hasChildren ? "cursor-pointer" : ""
|
||||
}`}
|
||||
onClick={
|
||||
hasChildren ? () => setIsContentVisible(!isContentVisible) : undefined
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package size={18} className="text-gray-600 dark:text-gray-400" />
|
||||
{packages.length > 0 && (
|
||||
<div className="text-gray-800 dark:text-gray-200 font-semibold text-base">
|
||||
<div className="font-normal">
|
||||
Do you want to install these packages?
|
||||
</div>{" "}
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{packages.map((p: string) => (
|
||||
<span
|
||||
className="cursor-pointer text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
key={p}
|
||||
onClick={() => {
|
||||
IpcClient.getInstance().openExternalUrl(
|
||||
`https://www.npmjs.com/package/${p}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasChildren && (
|
||||
<div className="flex items-center">
|
||||
{isContentVisible ? (
|
||||
<ChevronsDownUp
|
||||
size={20}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
/>
|
||||
) : (
|
||||
<ChevronsUpDown
|
||||
size={20}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{packages.length > 0 && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
Make sure these packages are what you want.{" "}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show content if it's visible and has children */}
|
||||
{isContentVisible && hasChildren && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs">
|
||||
<CodeHighlight className="language-shell">{children}</CodeHighlight>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
89
src/components/chat/DyadAddIntegration.tsx
Normal file
89
src/components/chat/DyadAddIntegration.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { showError } from "@/lib/toast";
|
||||
import { useLoadApp } from "@/hooks/useLoadApp";
|
||||
|
||||
interface DyadAddIntegrationProps {
|
||||
node: {
|
||||
properties: {
|
||||
provider: string;
|
||||
};
|
||||
};
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DyadAddIntegration: React.FC<DyadAddIntegrationProps> = ({
|
||||
node,
|
||||
children,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { provider } = node.properties;
|
||||
const appId = useAtomValue(selectedAppIdAtom);
|
||||
const { app } = useLoadApp(appId);
|
||||
|
||||
const handleSetupClick = () => {
|
||||
if (!appId) {
|
||||
showError("No app ID found");
|
||||
return;
|
||||
}
|
||||
navigate({ to: "/app-details", search: { appId } });
|
||||
};
|
||||
|
||||
if (app?.supabaseProjectName) {
|
||||
return (
|
||||
<div className="flex flex-col my-2 p-3 border border-green-300 rounded-lg bg-green-50 shadow-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg
|
||||
className="w-5 h-5 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
fill="#bbf7d0"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12l2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-semibold text-green-800">
|
||||
Supabase integration complete
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-green-900">
|
||||
<p>
|
||||
This app is connected to Supabase project:{" "}
|
||||
<span className="font-mono font-medium bg-green-100 px-1 py-0.5 rounded">
|
||||
{app.supabaseProjectName}
|
||||
</span>
|
||||
</p>
|
||||
<p>Click the chat suggestion "Keep going" to continue.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 my-2 p-3 border rounded-md bg-secondary/10">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">Integrate with {provider}?</div>
|
||||
<div className="text-muted-foreground text-xs">{children}</div>
|
||||
</div>
|
||||
<Button onClick={handleSetupClick} className="self-start w-full">
|
||||
Set up {provider}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
117
src/components/chat/DyadCodebaseContext.tsx
Normal file
117
src/components/chat/DyadCodebaseContext.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ChevronUp, ChevronDown, Code2, FileText } from "lucide-react";
|
||||
import { CustomTagState } from "./stateTypes";
|
||||
|
||||
interface DyadCodebaseContextProps {
|
||||
children: React.ReactNode;
|
||||
node?: {
|
||||
properties?: {
|
||||
files?: string;
|
||||
state?: CustomTagState;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const DyadCodebaseContext: React.FC<DyadCodebaseContextProps> = ({
|
||||
node,
|
||||
}) => {
|
||||
const state = node?.properties?.state as CustomTagState;
|
||||
const inProgress = state === "pending";
|
||||
const [isExpanded, setIsExpanded] = useState(inProgress);
|
||||
const files = node?.properties?.files?.split(",") || [];
|
||||
|
||||
// Collapse when transitioning from in-progress to not-in-progress
|
||||
useEffect(() => {
|
||||
if (!inProgress && isExpanded) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
}, [inProgress]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative bg-(--background-lightest) dark:bg-zinc-900 hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer ${
|
||||
inProgress ? "border-blue-500" : "border-border"
|
||||
}`}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
role="button"
|
||||
aria-expanded={isExpanded}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setIsExpanded(!isExpanded);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Top-left label badge */}
|
||||
<div
|
||||
className="absolute top-2 left-2 flex items-center gap-1 px-2 py-0.5 rounded text-xs font-semibold text-blue-500 bg-white dark:bg-zinc-900"
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<Code2 size={16} className="text-blue-500" />
|
||||
<span>Codebase Context</span>
|
||||
</div>
|
||||
|
||||
{/* File count when collapsed */}
|
||||
{files.length > 0 && (
|
||||
<div className="absolute top-2 left-40 flex items-center">
|
||||
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-zinc-800 text-xs rounded text-gray-600 dark:text-gray-300">
|
||||
Using {files.length} file{files.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Indicator icon */}
|
||||
<div className="absolute top-2 right-2 p-1 text-gray-500">
|
||||
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</div>
|
||||
|
||||
{/* Main content with smooth transition */}
|
||||
<div
|
||||
className="pt-6 overflow-hidden transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
maxHeight: isExpanded ? "1000px" : "0px",
|
||||
opacity: isExpanded ? 1 : 0,
|
||||
marginBottom: isExpanded ? "0" : "-6px", // Compensate for padding
|
||||
}}
|
||||
>
|
||||
{/* File list when expanded */}
|
||||
{files.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{files.map((file, index) => {
|
||||
const filePath = file.trim();
|
||||
const fileName = filePath.split("/").pop() || filePath;
|
||||
const pathPart =
|
||||
filePath.substring(0, filePath.length - fileName.length) ||
|
||||
"";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="px-2 py-1 bg-gray-100 dark:bg-zinc-800 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileText
|
||||
size={14}
|
||||
className="text-gray-500 dark:text-gray-400 flex-shrink-0"
|
||||
/>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{fileName}
|
||||
</div>
|
||||
</div>
|
||||
{pathPart && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 ml-5">
|
||||
{pathPart}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
45
src/components/chat/DyadDelete.tsx
Normal file
45
src/components/chat/DyadDelete.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type React from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
interface DyadDeleteProps {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export const DyadDelete: React.FC<DyadDeleteProps> = ({
|
||||
children,
|
||||
node,
|
||||
path: pathProp,
|
||||
}) => {
|
||||
// Use props directly if provided, otherwise extract from node
|
||||
const path = pathProp || node?.properties?.path || "";
|
||||
|
||||
// Extract filename from path
|
||||
const fileName = path ? path.split("/").pop() : "";
|
||||
|
||||
return (
|
||||
<div className="bg-(--background-lightest) rounded-lg px-4 py-2 border border-red-500 my-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2 size={16} className="text-red-500" />
|
||||
{fileName && (
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
|
||||
{fileName}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-xs text-red-500 font-medium">Delete</div>
|
||||
</div>
|
||||
</div>
|
||||
{path && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">
|
||||
{path}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300 mt-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
110
src/components/chat/DyadEdit.tsx
Normal file
110
src/components/chat/DyadEdit.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import type React from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Loader,
|
||||
CircleX,
|
||||
Rabbit,
|
||||
} from "lucide-react";
|
||||
import { CodeHighlight } from "./CodeHighlight";
|
||||
import { CustomTagState } from "./stateTypes";
|
||||
|
||||
interface DyadEditProps {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
path?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const DyadEdit: React.FC<DyadEditProps> = ({
|
||||
children,
|
||||
node,
|
||||
path: pathProp,
|
||||
description: descriptionProp,
|
||||
}) => {
|
||||
const [isContentVisible, setIsContentVisible] = useState(false);
|
||||
|
||||
// Use props directly if provided, otherwise extract from node
|
||||
const path = pathProp || node?.properties?.path || "";
|
||||
const description = descriptionProp || node?.properties?.description || "";
|
||||
const state = node?.properties?.state as CustomTagState;
|
||||
const inProgress = state === "pending";
|
||||
const aborted = state === "aborted";
|
||||
|
||||
// Extract filename from path
|
||||
const fileName = path ? path.split("/").pop() : "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer ${
|
||||
inProgress
|
||||
? "border-amber-500"
|
||||
: aborted
|
||||
? "border-red-500"
|
||||
: "border-border"
|
||||
}`}
|
||||
onClick={() => setIsContentVisible(!isContentVisible)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center">
|
||||
<Rabbit size={16} />
|
||||
<span className="bg-blue-500 text-white text-xs px-1.5 py-0.5 rounded ml-1 font-medium">
|
||||
Turbo Edit
|
||||
</span>
|
||||
</div>
|
||||
{fileName && (
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
|
||||
{fileName}
|
||||
</span>
|
||||
)}
|
||||
{inProgress && (
|
||||
<div className="flex items-center text-amber-600 text-xs">
|
||||
<Loader size={14} className="mr-1 animate-spin" />
|
||||
<span>Editing...</span>
|
||||
</div>
|
||||
)}
|
||||
{aborted && (
|
||||
<div className="flex items-center text-red-600 text-xs">
|
||||
<CircleX size={14} className="mr-1" />
|
||||
<span>Did not finish</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{isContentVisible ? (
|
||||
<ChevronsDownUp
|
||||
size={20}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
/>
|
||||
) : (
|
||||
<ChevronsUpDown
|
||||
size={20}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{path && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">
|
||||
{path}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-medium">Summary: </span>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{isContentVisible && (
|
||||
<div className="text-xs">
|
||||
<CodeHighlight className="language-typescript">
|
||||
{children}
|
||||
</CodeHighlight>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
85
src/components/chat/DyadExecuteSql.tsx
Normal file
85
src/components/chat/DyadExecuteSql.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import type React from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Database,
|
||||
Loader,
|
||||
CircleX,
|
||||
} from "lucide-react";
|
||||
import { CodeHighlight } from "./CodeHighlight";
|
||||
import { CustomTagState } from "./stateTypes";
|
||||
|
||||
interface DyadExecuteSqlProps {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const DyadExecuteSql: React.FC<DyadExecuteSqlProps> = ({
|
||||
children,
|
||||
node,
|
||||
description,
|
||||
}) => {
|
||||
const [isContentVisible, setIsContentVisible] = useState(false);
|
||||
const state = node?.properties?.state as CustomTagState;
|
||||
const inProgress = state === "pending";
|
||||
const aborted = state === "aborted";
|
||||
const queryDescription = description || node?.properties?.description;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer ${
|
||||
inProgress
|
||||
? "border-amber-500"
|
||||
: aborted
|
||||
? "border-red-500"
|
||||
: "border-border"
|
||||
}`}
|
||||
onClick={() => setIsContentVisible(!isContentVisible)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database size={16} />
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
|
||||
<span className="font-bold mr-2 outline-2 outline-gray-200 dark:outline-gray-700 bg-gray-100 dark:bg-gray-800 rounded-md px-1">
|
||||
SQL
|
||||
</span>
|
||||
{queryDescription}
|
||||
</span>
|
||||
{inProgress && (
|
||||
<div className="flex items-center text-amber-600 text-xs">
|
||||
<Loader size={14} className="mr-1 animate-spin" />
|
||||
<span>Executing...</span>
|
||||
</div>
|
||||
)}
|
||||
{aborted && (
|
||||
<div className="flex items-center text-red-600 text-xs">
|
||||
<CircleX size={14} className="mr-1" />
|
||||
<span>Did not finish</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{isContentVisible ? (
|
||||
<ChevronsDownUp
|
||||
size={20}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
/>
|
||||
) : (
|
||||
<ChevronsUpDown
|
||||
size={20}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isContentVisible && (
|
||||
<div className="text-xs">
|
||||
<CodeHighlight className="language-sql">{children}</CodeHighlight>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
424
src/components/chat/DyadMarkdownParser.tsx
Normal file
424
src/components/chat/DyadMarkdownParser.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
import React, { useMemo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
import { DyadWrite } from "./DyadWrite";
|
||||
import { DyadRename } from "./DyadRename";
|
||||
import { DyadDelete } from "./DyadDelete";
|
||||
import { DyadAddDependency } from "./DyadAddDependency";
|
||||
import { DyadExecuteSql } from "./DyadExecuteSql";
|
||||
import { DyadAddIntegration } from "./DyadAddIntegration";
|
||||
import { DyadEdit } from "./DyadEdit";
|
||||
import { DyadCodebaseContext } from "./DyadCodebaseContext";
|
||||
import { DyadThink } from "./DyadThink";
|
||||
import { CodeHighlight } from "./CodeHighlight";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { isStreamingAtom } from "@/atoms/chatAtoms";
|
||||
import { CustomTagState } from "./stateTypes";
|
||||
import { DyadOutput } from "./DyadOutput";
|
||||
import { DyadProblemSummary } from "./DyadProblemSummary";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
|
||||
interface DyadMarkdownParserProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
type CustomTagInfo = {
|
||||
tag: string;
|
||||
attributes: Record<string, string>;
|
||||
content: string;
|
||||
fullMatch: string;
|
||||
inProgress?: boolean;
|
||||
};
|
||||
|
||||
type ContentPiece =
|
||||
| { type: "markdown"; content: string }
|
||||
| { type: "custom-tag"; tagInfo: CustomTagInfo };
|
||||
|
||||
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={{
|
||||
code: CodeHighlight,
|
||||
a: customLink,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom component to parse markdown content with Dyad-specific tags
|
||||
*/
|
||||
export const DyadMarkdownParser: React.FC<DyadMarkdownParserProps> = ({
|
||||
content,
|
||||
}) => {
|
||||
const isStreaming = useAtomValue(isStreamingAtom);
|
||||
// Extract content pieces (markdown and custom tags)
|
||||
const contentPieces = useMemo(() => {
|
||||
return parseCustomTags(content);
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{contentPieces.map((piece, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{piece.type === "markdown"
|
||||
? piece.content && (
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
code: CodeHighlight,
|
||||
a: customLink,
|
||||
}}
|
||||
>
|
||||
{piece.content}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
: renderCustomTag(piece.tagInfo, { isStreaming })}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Pre-process content to handle unclosed custom tags
|
||||
* Adds closing tags at the end of the content for any unclosed custom tags
|
||||
* Assumes the opening tags are complete and valid
|
||||
* Returns the processed content and a map of in-progress tags
|
||||
*/
|
||||
function preprocessUnclosedTags(content: string): {
|
||||
processedContent: string;
|
||||
inProgressTags: Map<string, Set<number>>;
|
||||
} {
|
||||
const customTagNames = [
|
||||
"dyad-write",
|
||||
"dyad-rename",
|
||||
"dyad-delete",
|
||||
"dyad-add-dependency",
|
||||
"dyad-execute-sql",
|
||||
"dyad-add-integration",
|
||||
"dyad-output",
|
||||
"dyad-problem-report",
|
||||
"dyad-chat-summary",
|
||||
"dyad-edit",
|
||||
"dyad-codebase-context",
|
||||
"think",
|
||||
];
|
||||
|
||||
let processedContent = content;
|
||||
// Map to track which tags are in progress and their positions
|
||||
const inProgressTags = new Map<string, Set<number>>();
|
||||
|
||||
// For each tag type, check if there are unclosed tags
|
||||
for (const tagName of customTagNames) {
|
||||
// Count opening and closing tags
|
||||
const openTagPattern = new RegExp(`<${tagName}(?:\\s[^>]*)?>`, "g");
|
||||
const closeTagPattern = new RegExp(`</${tagName}>`, "g");
|
||||
|
||||
// Track the positions of opening tags
|
||||
const openingMatches: RegExpExecArray[] = [];
|
||||
let match;
|
||||
|
||||
// Reset regex lastIndex to start from the beginning
|
||||
openTagPattern.lastIndex = 0;
|
||||
|
||||
while ((match = openTagPattern.exec(processedContent)) !== null) {
|
||||
openingMatches.push({ ...match });
|
||||
}
|
||||
|
||||
const openCount = openingMatches.length;
|
||||
const closeCount = (processedContent.match(closeTagPattern) || []).length;
|
||||
|
||||
// If we have more opening than closing tags
|
||||
const missingCloseTags = openCount - closeCount;
|
||||
if (missingCloseTags > 0) {
|
||||
// Add the required number of closing tags at the end
|
||||
processedContent += Array(missingCloseTags)
|
||||
.fill(`</${tagName}>`)
|
||||
.join("");
|
||||
|
||||
// Mark the last N tags as in progress where N is the number of missing closing tags
|
||||
const inProgressIndexes = new Set<number>();
|
||||
const startIndex = openCount - missingCloseTags;
|
||||
for (let i = startIndex; i < openCount; i++) {
|
||||
inProgressIndexes.add(openingMatches[i].index);
|
||||
}
|
||||
inProgressTags.set(tagName, inProgressIndexes);
|
||||
}
|
||||
}
|
||||
|
||||
return { processedContent, inProgressTags };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the content to extract custom tags and markdown sections into a unified array
|
||||
*/
|
||||
function parseCustomTags(content: string): ContentPiece[] {
|
||||
const { processedContent, inProgressTags } = preprocessUnclosedTags(content);
|
||||
|
||||
const customTagNames = [
|
||||
"dyad-write",
|
||||
"dyad-rename",
|
||||
"dyad-delete",
|
||||
"dyad-add-dependency",
|
||||
"dyad-execute-sql",
|
||||
"dyad-add-integration",
|
||||
"dyad-output",
|
||||
"dyad-problem-report",
|
||||
"dyad-chat-summary",
|
||||
"dyad-edit",
|
||||
"dyad-codebase-context",
|
||||
"think",
|
||||
];
|
||||
|
||||
const tagPattern = new RegExp(
|
||||
`<(${customTagNames.join("|")})\\s*([^>]*)>(.*?)<\\/\\1>`,
|
||||
"gs",
|
||||
);
|
||||
|
||||
const contentPieces: ContentPiece[] = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
// Find all custom tags
|
||||
while ((match = tagPattern.exec(processedContent)) !== null) {
|
||||
const [fullMatch, tag, attributesStr, tagContent] = match;
|
||||
const startIndex = match.index;
|
||||
|
||||
// Add the markdown content before this tag
|
||||
if (startIndex > lastIndex) {
|
||||
contentPieces.push({
|
||||
type: "markdown",
|
||||
content: processedContent.substring(lastIndex, startIndex),
|
||||
});
|
||||
}
|
||||
|
||||
// Parse attributes
|
||||
const attributes: Record<string, string> = {};
|
||||
const attrPattern = /(\w+)="([^"]*)"/g;
|
||||
let attrMatch;
|
||||
while ((attrMatch = attrPattern.exec(attributesStr)) !== null) {
|
||||
attributes[attrMatch[1]] = attrMatch[2];
|
||||
}
|
||||
|
||||
// Check if this tag was marked as in progress
|
||||
const tagInProgressSet = inProgressTags.get(tag);
|
||||
const isInProgress = tagInProgressSet?.has(startIndex);
|
||||
|
||||
// Add the tag info
|
||||
contentPieces.push({
|
||||
type: "custom-tag",
|
||||
tagInfo: {
|
||||
tag,
|
||||
attributes,
|
||||
content: tagContent,
|
||||
fullMatch,
|
||||
inProgress: isInProgress || false,
|
||||
},
|
||||
});
|
||||
|
||||
lastIndex = startIndex + fullMatch.length;
|
||||
}
|
||||
|
||||
// Add the remaining markdown content
|
||||
if (lastIndex < processedContent.length) {
|
||||
contentPieces.push({
|
||||
type: "markdown",
|
||||
content: processedContent.substring(lastIndex),
|
||||
});
|
||||
}
|
||||
|
||||
return contentPieces;
|
||||
}
|
||||
|
||||
function getState({
|
||||
isStreaming,
|
||||
inProgress,
|
||||
}: {
|
||||
isStreaming?: boolean;
|
||||
inProgress?: boolean;
|
||||
}): CustomTagState {
|
||||
if (!inProgress) {
|
||||
return "finished";
|
||||
}
|
||||
return isStreaming ? "pending" : "aborted";
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a custom tag based on its type
|
||||
*/
|
||||
function renderCustomTag(
|
||||
tagInfo: CustomTagInfo,
|
||||
{ isStreaming }: { isStreaming: boolean },
|
||||
): React.ReactNode {
|
||||
const { tag, attributes, content, inProgress } = tagInfo;
|
||||
|
||||
switch (tag) {
|
||||
case "think":
|
||||
return (
|
||||
<DyadThink
|
||||
node={{
|
||||
properties: {
|
||||
state: getState({ isStreaming, inProgress }),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DyadThink>
|
||||
);
|
||||
case "dyad-write":
|
||||
return (
|
||||
<DyadWrite
|
||||
node={{
|
||||
properties: {
|
||||
path: attributes.path || "",
|
||||
description: attributes.description || "",
|
||||
state: getState({ isStreaming, inProgress }),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DyadWrite>
|
||||
);
|
||||
|
||||
case "dyad-rename":
|
||||
return (
|
||||
<DyadRename
|
||||
node={{
|
||||
properties: {
|
||||
from: attributes.from || "",
|
||||
to: attributes.to || "",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DyadRename>
|
||||
);
|
||||
|
||||
case "dyad-delete":
|
||||
return (
|
||||
<DyadDelete
|
||||
node={{
|
||||
properties: {
|
||||
path: attributes.path || "",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DyadDelete>
|
||||
);
|
||||
|
||||
case "dyad-add-dependency":
|
||||
return (
|
||||
<DyadAddDependency
|
||||
node={{
|
||||
properties: {
|
||||
packages: attributes.packages || "",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DyadAddDependency>
|
||||
);
|
||||
|
||||
case "dyad-execute-sql":
|
||||
return (
|
||||
<DyadExecuteSql
|
||||
node={{
|
||||
properties: {
|
||||
state: getState({ isStreaming, inProgress }),
|
||||
description: attributes.description || "",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DyadExecuteSql>
|
||||
);
|
||||
|
||||
case "dyad-add-integration":
|
||||
return (
|
||||
<DyadAddIntegration
|
||||
node={{
|
||||
properties: {
|
||||
provider: attributes.provider || "",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DyadAddIntegration>
|
||||
);
|
||||
|
||||
case "dyad-edit":
|
||||
return (
|
||||
<DyadEdit
|
||||
node={{
|
||||
properties: {
|
||||
path: attributes.path || "",
|
||||
description: attributes.description || "",
|
||||
state: getState({ isStreaming, inProgress }),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DyadEdit>
|
||||
);
|
||||
|
||||
case "dyad-codebase-context":
|
||||
return (
|
||||
<DyadCodebaseContext
|
||||
node={{
|
||||
properties: {
|
||||
files: attributes.files || "",
|
||||
state: getState({ isStreaming, inProgress }),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DyadCodebaseContext>
|
||||
);
|
||||
|
||||
case "dyad-output":
|
||||
return (
|
||||
<DyadOutput
|
||||
type={attributes.type as "warning" | "error"}
|
||||
message={attributes.message}
|
||||
>
|
||||
{content}
|
||||
</DyadOutput>
|
||||
);
|
||||
|
||||
case "dyad-problem-report":
|
||||
return (
|
||||
<DyadProblemSummary summary={attributes.summary}>
|
||||
{content}
|
||||
</DyadProblemSummary>
|
||||
);
|
||||
|
||||
case "dyad-chat-summary":
|
||||
// Don't render anything for dyad-chat-summary
|
||||
return null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
108
src/components/chat/DyadOutput.tsx
Normal file
108
src/components/chat/DyadOutput.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
interface DyadOutputProps {
|
||||
type: "error" | "warning";
|
||||
message?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DyadOutput: React.FC<DyadOutputProps> = ({
|
||||
type,
|
||||
message,
|
||||
children,
|
||||
}) => {
|
||||
const [isContentVisible, setIsContentVisible] = useState(false);
|
||||
const selectedChatId = useAtomValue(selectedChatIdAtom);
|
||||
const { streamMessage } = useStreamChat();
|
||||
|
||||
// If the type is not warning, it is an error (in case LLM gives a weird "type")
|
||||
const isError = type !== "warning";
|
||||
const borderColor = isError ? "border-red-500" : "border-amber-500";
|
||||
const iconColor = isError ? "text-red-500" : "text-amber-500";
|
||||
const icon = isError ? (
|
||||
<XCircle size={16} className={iconColor} />
|
||||
) : (
|
||||
<AlertTriangle size={16} className={iconColor} />
|
||||
);
|
||||
const label = isError ? "Error" : "Warning";
|
||||
|
||||
const handleAIFix = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (message && selectedChatId) {
|
||||
streamMessage({
|
||||
prompt: `Fix the error: ${message}`,
|
||||
chatId: selectedChatId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer min-h-18 ${borderColor}`}
|
||||
onClick={() => setIsContentVisible(!isContentVisible)}
|
||||
>
|
||||
{/* Top-left label badge */}
|
||||
<div
|
||||
className={`absolute top-2 left-2 flex items-center gap-1 px-2 py-0.5 rounded text-xs font-semibold ${iconColor} bg-white dark:bg-gray-900`}
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
|
||||
{/* Fix with AI button - always visible for errors */}
|
||||
{isError && message && (
|
||||
<div className="absolute top-9 left-2">
|
||||
<button
|
||||
onClick={handleAIFix}
|
||||
className="cursor-pointer flex items-center justify-center bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800 text-white rounded text-xs p-1 w-24 h-6"
|
||||
>
|
||||
<Sparkles size={16} className="mr-1" />
|
||||
<span>Fix with AI</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content, padded to avoid label */}
|
||||
<div className="flex items-center justify-between pl-24 pr-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{message && (
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
|
||||
{message.slice(0, isContentVisible ? undefined : 100) +
|
||||
(!isContentVisible ? "..." : "")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{isContentVisible ? (
|
||||
<ChevronsDownUp
|
||||
size={20}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
/>
|
||||
) : (
|
||||
<ChevronsUpDown
|
||||
size={20}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
{isContentVisible && children && (
|
||||
<div className="mt-4 pl-20 text-sm text-gray-800 dark:text-gray-200">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
156
src/components/chat/DyadProblemSummary.tsx
Normal file
156
src/components/chat/DyadProblemSummary.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
AlertTriangle,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import type { Problem } from "@/ipc/ipc_types";
|
||||
|
||||
type ProblemWithoutSnippet = Omit<Problem, "snippet">;
|
||||
|
||||
interface DyadProblemSummaryProps {
|
||||
summary?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface ProblemItemProps {
|
||||
problem: ProblemWithoutSnippet;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const ProblemItem: React.FC<ProblemItemProps> = ({ problem, index }) => {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-2 px-3 border-b border-gray-200 dark:border-gray-700 last:border-b-0">
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mt-0.5">
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText size={14} className="text-gray-500 flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{problem.file}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{problem.line}:{problem.column}
|
||||
</span>
|
||||
<span className="text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded text-gray-600 dark:text-gray-300">
|
||||
TS{problem.code}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
|
||||
{problem.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DyadProblemSummary: React.FC<DyadProblemSummaryProps> = ({
|
||||
summary,
|
||||
children,
|
||||
}) => {
|
||||
const [isContentVisible, setIsContentVisible] = useState(false);
|
||||
|
||||
// Parse problems from children content if available
|
||||
const problems: ProblemWithoutSnippet[] = React.useMemo(() => {
|
||||
if (!children || typeof children !== "string") return [];
|
||||
|
||||
// Parse structured format with <problem> tags
|
||||
const problemTagRegex =
|
||||
/<problem\s+file="([^"]+)"\s+line="(\d+)"\s+column="(\d+)"\s+code="(\d+)">([^<]+)<\/problem>/g;
|
||||
const problems: ProblemWithoutSnippet[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = problemTagRegex.exec(children)) !== null) {
|
||||
try {
|
||||
problems.push({
|
||||
file: match[1],
|
||||
line: parseInt(match[2], 10),
|
||||
column: parseInt(match[3], 10),
|
||||
message: match[5].trim(),
|
||||
code: parseInt(match[4], 10),
|
||||
});
|
||||
} catch {
|
||||
return [
|
||||
{
|
||||
file: "unknown",
|
||||
line: 0,
|
||||
column: 0,
|
||||
message: children,
|
||||
code: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}, [children]);
|
||||
|
||||
const totalProblems = problems.length;
|
||||
const displaySummary =
|
||||
summary || `${totalProblems} problems found (TypeScript errors)`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border border-border my-2 cursor-pointer"
|
||||
onClick={() => setIsContentVisible(!isContentVisible)}
|
||||
data-testid="problem-summary"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle
|
||||
size={16}
|
||||
className="text-amber-600 dark:text-amber-500"
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
|
||||
<span className="font-bold mr-2 outline-2 outline-amber-200 dark:outline-amber-700 bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-200 rounded-md px-1">
|
||||
Auto-fix
|
||||
</span>
|
||||
{displaySummary}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{isContentVisible ? (
|
||||
<ChevronsDownUp
|
||||
size={20}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
/>
|
||||
) : (
|
||||
<ChevronsUpDown
|
||||
size={20}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area - show individual problems */}
|
||||
{isContentVisible && totalProblems > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{problems.map((problem, index) => (
|
||||
<ProblemItem
|
||||
key={`${problem.file}-${problem.line}-${problem.column}-${index}`}
|
||||
problem={problem}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback content area for raw children */}
|
||||
{isContentVisible && totalProblems === 0 && children && (
|
||||
<div className="mt-4 text-sm text-gray-800 dark:text-gray-200">
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded">
|
||||
{children}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
61
src/components/chat/DyadRename.tsx
Normal file
61
src/components/chat/DyadRename.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type React from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { FileEdit } from "lucide-react";
|
||||
|
||||
interface DyadRenameProps {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export const DyadRename: React.FC<DyadRenameProps> = ({
|
||||
children,
|
||||
node,
|
||||
from: fromProp,
|
||||
to: toProp,
|
||||
}) => {
|
||||
// Use props directly if provided, otherwise extract from node
|
||||
const from = fromProp || node?.properties?.from || "";
|
||||
const to = toProp || node?.properties?.to || "";
|
||||
|
||||
// Extract filenames from paths
|
||||
const fromFileName = from ? from.split("/").pop() : "";
|
||||
const toFileName = to ? to.split("/").pop() : "";
|
||||
|
||||
return (
|
||||
<div className="bg-(--background-lightest) rounded-lg px-4 py-2 border border-amber-500 my-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileEdit size={16} className="text-amber-500" />
|
||||
{(fromFileName || toFileName) && (
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
|
||||
{fromFileName && toFileName
|
||||
? `${fromFileName} → ${toFileName}`
|
||||
: fromFileName || toFileName}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-xs text-amber-500 font-medium">Rename</div>
|
||||
</div>
|
||||
</div>
|
||||
{(from || to) && (
|
||||
<div className="flex flex-col text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">
|
||||
{from && (
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">From:</span>{" "}
|
||||
{from}
|
||||
</div>
|
||||
)}
|
||||
{to && (
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">To:</span> {to}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300 mt-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
96
src/components/chat/DyadThink.tsx
Normal file
96
src/components/chat/DyadThink.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Brain, ChevronDown, ChevronUp, Loader } from "lucide-react";
|
||||
import { VanillaMarkdownParser } from "./DyadMarkdownParser";
|
||||
import { CustomTagState } from "./stateTypes";
|
||||
import { DyadTokenSavings } from "./DyadTokenSavings";
|
||||
|
||||
interface DyadThinkProps {
|
||||
node?: any;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DyadThink: React.FC<DyadThinkProps> = ({ children, node }) => {
|
||||
const state = node?.properties?.state as CustomTagState;
|
||||
const inProgress = state === "pending";
|
||||
const [isExpanded, setIsExpanded] = useState(inProgress);
|
||||
|
||||
// Check if content matches token savings format
|
||||
const tokenSavingsMatch =
|
||||
typeof children === "string"
|
||||
? children.match(
|
||||
/^dyad-token-savings\?original-tokens=([0-9.]+)&smart-context-tokens=([0-9.]+)$/,
|
||||
)
|
||||
: null;
|
||||
|
||||
// If it's token savings format, render DyadTokenSavings component
|
||||
if (tokenSavingsMatch) {
|
||||
const originalTokens = parseFloat(tokenSavingsMatch[1]);
|
||||
const smartContextTokens = parseFloat(tokenSavingsMatch[2]);
|
||||
return (
|
||||
<DyadTokenSavings
|
||||
originalTokens={originalTokens}
|
||||
smartContextTokens={smartContextTokens}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Collapse when transitioning from in-progress to not-in-progress
|
||||
useEffect(() => {
|
||||
if (!inProgress && isExpanded) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
}, [inProgress]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative bg-(--background-lightest) dark:bg-zinc-900 hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer ${
|
||||
inProgress ? "border-purple-500" : "border-border"
|
||||
}`}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
role="button"
|
||||
aria-expanded={isExpanded}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setIsExpanded(!isExpanded);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Top-left label badge */}
|
||||
<div
|
||||
className="absolute top-2 left-2 flex items-center gap-1 px-2 py-0.5 rounded text-xs font-semibold text-purple-500 bg-white dark:bg-zinc-900"
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<Brain size={16} className="text-purple-500" />
|
||||
<span>Thinking</span>
|
||||
{inProgress && (
|
||||
<Loader size={14} className="ml-1 text-purple-500 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Indicator icon */}
|
||||
<div className="absolute top-2 right-2 p-1 text-gray-500">
|
||||
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</div>
|
||||
|
||||
{/* Main content with smooth transition */}
|
||||
<div
|
||||
className="pt-6 overflow-hidden transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
maxHeight: isExpanded ? "none" : "0px",
|
||||
opacity: isExpanded ? 1 : 0,
|
||||
marginBottom: isExpanded ? "0" : "-6px", // Compensate for padding
|
||||
}}
|
||||
>
|
||||
<div className="px-0 text-sm text-gray-600 dark:text-gray-300">
|
||||
{typeof children === "string" ? (
|
||||
<VanillaMarkdownParser content={children} />
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
36
src/components/chat/DyadTokenSavings.tsx
Normal file
36
src/components/chat/DyadTokenSavings.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { Zap } from "lucide-react";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip";
|
||||
|
||||
interface DyadTokenSavingsProps {
|
||||
originalTokens: number;
|
||||
smartContextTokens: number;
|
||||
}
|
||||
|
||||
export const DyadTokenSavings: React.FC<DyadTokenSavingsProps> = ({
|
||||
originalTokens,
|
||||
smartContextTokens,
|
||||
}) => {
|
||||
const tokensSaved = originalTokens - smartContextTokens;
|
||||
const percentageSaved = Math.round((tokensSaved / originalTokens) * 100);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="bg-green-50 dark:bg-green-950 hover:bg-green-100 dark:hover:bg-green-900 rounded-lg px-4 py-2 border border-green-200 dark:border-green-800 my-2 cursor-pointer">
|
||||
<div className="flex items-center gap-2 text-green-700 dark:text-green-300">
|
||||
<Zap size={16} className="text-green-600 dark:text-green-400" />
|
||||
<span className="text-xs font-medium">
|
||||
Saved {percentageSaved}% of codebase tokens with Smart Context
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center">
|
||||
<div className="text-left">
|
||||
Saved {Math.round(tokensSaved).toLocaleString()} tokens
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
108
src/components/chat/DyadWrite.tsx
Normal file
108
src/components/chat/DyadWrite.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import type React from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Pencil,
|
||||
Loader,
|
||||
CircleX,
|
||||
} from "lucide-react";
|
||||
import { CodeHighlight } from "./CodeHighlight";
|
||||
import { CustomTagState } from "./stateTypes";
|
||||
|
||||
interface DyadWriteProps {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
path?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const DyadWrite: React.FC<DyadWriteProps> = ({
|
||||
children,
|
||||
node,
|
||||
path: pathProp,
|
||||
description: descriptionProp,
|
||||
}) => {
|
||||
const [isContentVisible, setIsContentVisible] = useState(false);
|
||||
|
||||
// Use props directly if provided, otherwise extract from node
|
||||
const path = pathProp || node?.properties?.path || "";
|
||||
const description = descriptionProp || node?.properties?.description || "";
|
||||
const state = node?.properties?.state as CustomTagState;
|
||||
const inProgress = state === "pending";
|
||||
const aborted = state === "aborted";
|
||||
|
||||
// Extract filename from path
|
||||
const fileName = path ? path.split("/").pop() : "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer ${
|
||||
inProgress
|
||||
? "border-amber-500"
|
||||
: aborted
|
||||
? "border-red-500"
|
||||
: "border-border"
|
||||
}`}
|
||||
onClick={() => setIsContentVisible(!isContentVisible)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil size={16} />
|
||||
{fileName && (
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
|
||||
{fileName}
|
||||
</span>
|
||||
)}
|
||||
{inProgress && (
|
||||
<div className="flex items-center text-amber-600 text-xs">
|
||||
<Loader size={14} className="mr-1 animate-spin" />
|
||||
<span>Writing...</span>
|
||||
</div>
|
||||
)}
|
||||
{aborted && (
|
||||
<div className="flex items-center text-red-600 text-xs">
|
||||
<CircleX size={14} className="mr-1" />
|
||||
<span>Did not finish</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{isContentVisible ? (
|
||||
<ChevronsDownUp
|
||||
size={20}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
/>
|
||||
) : (
|
||||
<ChevronsUpDown
|
||||
size={20}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{path && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">
|
||||
{path}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-medium">Summary: </span>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{isContentVisible && (
|
||||
<div
|
||||
className="text-xs cursor-text"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<CodeHighlight className="language-typescript">
|
||||
{children}
|
||||
</CodeHighlight>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
133
src/components/chat/FileAttachmentDropdown.tsx
Normal file
133
src/components/chat/FileAttachmentDropdown.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Paperclip, MessageSquare, Upload } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRef } from "react";
|
||||
|
||||
interface FileAttachmentDropdownProps {
|
||||
onFileSelect: (
|
||||
files: FileList,
|
||||
type: "chat-context" | "upload-to-codebase",
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FileAttachmentDropdown({
|
||||
onFileSelect,
|
||||
disabled,
|
||||
className,
|
||||
}: FileAttachmentDropdownProps) {
|
||||
const chatContextFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const uploadToCodebaseFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleChatContextClick = () => {
|
||||
chatContextFileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleUploadToCodebaseClick = () => {
|
||||
uploadToCodebaseFileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
type: "chat-context" | "upload-to-codebase",
|
||||
) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
onFileSelect(e.target.files, type);
|
||||
// Clear the input value so the same file can be selected again
|
||||
e.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
title="Attach files"
|
||||
className={className}
|
||||
>
|
||||
<Paperclip size={20} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
onClick={handleChatContextClick}
|
||||
className="py-3 px-4"
|
||||
>
|
||||
<MessageSquare size={16} className="mr-2" />
|
||||
Attach file as chat context
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
Example use case: screenshot of the app to point out a UI
|
||||
issue
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
onClick={handleUploadToCodebaseClick}
|
||||
className="py-3 px-4"
|
||||
>
|
||||
<Upload size={16} className="mr-2" />
|
||||
Upload file to codebase
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
Example use case: add an image to use for your app
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<TooltipContent>Attach files</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Hidden file inputs */}
|
||||
<input
|
||||
type="file"
|
||||
data-testid="chat-context-file-input"
|
||||
ref={chatContextFileInputRef}
|
||||
onChange={(e) => handleFileChange(e, "chat-context")}
|
||||
className="hidden"
|
||||
multiple
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,.txt,.md,.js,.ts,.html,.css,.json,.csv"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
data-testid="upload-to-codebase-file-input"
|
||||
ref={uploadToCodebaseFileInputRef}
|
||||
onChange={(e) => handleFileChange(e, "upload-to-codebase")}
|
||||
className="hidden"
|
||||
multiple
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,.txt,.md,.js,.ts,.html,.css,.json,.csv"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
121
src/components/chat/HomeChatInput.tsx
Normal file
121
src/components/chat/HomeChatInput.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { SendIcon, StopCircleIcon } from "lucide-react";
|
||||
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { homeChatInputValueAtom } from "@/atoms/chatAtoms"; // Use a different atom for home input
|
||||
import { useAtom } from "jotai";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
import { useAttachments } from "@/hooks/useAttachments";
|
||||
import { AttachmentsList } from "./AttachmentsList";
|
||||
import { DragDropOverlay } from "./DragDropOverlay";
|
||||
import { FileAttachmentDropdown } from "./FileAttachmentDropdown";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { HomeSubmitOptions } from "@/pages/home";
|
||||
import { ChatInputControls } from "../ChatInputControls";
|
||||
import { LexicalChatInput } from "./LexicalChatInput";
|
||||
export function HomeChatInput({
|
||||
onSubmit,
|
||||
}: {
|
||||
onSubmit: (options?: HomeSubmitOptions) => void;
|
||||
}) {
|
||||
const posthog = usePostHog();
|
||||
const [inputValue, setInputValue] = useAtom(homeChatInputValueAtom);
|
||||
const { settings } = useSettings();
|
||||
const { isStreaming } = useStreamChat({
|
||||
hasChatId: false,
|
||||
}); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
|
||||
// Use the attachments hook
|
||||
const {
|
||||
attachments,
|
||||
isDraggingOver,
|
||||
handleFileSelect,
|
||||
removeAttachment,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
clearAttachments,
|
||||
handlePaste,
|
||||
} = useAttachments();
|
||||
|
||||
// Custom submit function that wraps the provided onSubmit
|
||||
const handleCustomSubmit = () => {
|
||||
if ((!inputValue.trim() && attachments.length === 0) || isStreaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the parent's onSubmit handler with attachments
|
||||
onSubmit({ attachments });
|
||||
|
||||
// Clear attachments as part of submission process
|
||||
clearAttachments();
|
||||
posthog.capture("chat:home_submit");
|
||||
};
|
||||
|
||||
if (!settings) {
|
||||
return null; // Or loading state
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-4" data-testid="home-chat-input-container">
|
||||
<div
|
||||
className={`relative flex flex-col space-y-2 border border-border rounded-lg bg-(--background-lighter) shadow-sm ${
|
||||
isDraggingOver ? "ring-2 ring-blue-500 border-blue-500" : ""
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Attachments list */}
|
||||
<AttachmentsList
|
||||
attachments={attachments}
|
||||
onRemove={removeAttachment}
|
||||
/>
|
||||
|
||||
{/* Drag and drop overlay */}
|
||||
<DragDropOverlay isDraggingOver={isDraggingOver} />
|
||||
|
||||
<div className="flex items-start space-x-2 ">
|
||||
<LexicalChatInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSubmit={handleCustomSubmit}
|
||||
onPaste={handlePaste}
|
||||
placeholder="Ask Dyad to build..."
|
||||
disabled={isStreaming}
|
||||
excludeCurrentApp={false}
|
||||
/>
|
||||
|
||||
{/* File attachment dropdown */}
|
||||
<FileAttachmentDropdown
|
||||
className="mt-1 mr-1"
|
||||
onFileSelect={handleFileSelect}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
|
||||
{isStreaming ? (
|
||||
<button
|
||||
className="px-2 py-2 mt-1 mr-2 text-(--sidebar-accent-fg) rounded-lg opacity-50 cursor-not-allowed" // Indicate disabled state
|
||||
title="Cancel generation (unavailable here)"
|
||||
>
|
||||
<StopCircleIcon size={20} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleCustomSubmit}
|
||||
disabled={!inputValue.trim() && attachments.length === 0}
|
||||
className="px-2 py-2 mt-1 mr-2 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg disabled:opacity-50"
|
||||
title="Send message"
|
||||
>
|
||||
<SendIcon size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-2 pb-2">
|
||||
<ChatInputControls />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
393
src/components/chat/LexicalChatInput.tsx
Normal file
393
src/components/chat/LexicalChatInput.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
$getRoot,
|
||||
$createParagraphNode,
|
||||
$createTextNode,
|
||||
EditorState,
|
||||
} from "lexical";
|
||||
import { LexicalComposer } from "@lexical/react/LexicalComposer";
|
||||
import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
|
||||
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
|
||||
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
|
||||
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
|
||||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
||||
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
|
||||
import {
|
||||
BeautifulMentionsPlugin,
|
||||
BeautifulMentionNode,
|
||||
$createBeautifulMentionNode,
|
||||
type BeautifulMentionsTheme,
|
||||
type BeautifulMentionsMenuItemProps,
|
||||
} from "lexical-beautiful-mentions";
|
||||
import { KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH } from "lexical";
|
||||
import { useLoadApps } from "@/hooks/useLoadApps";
|
||||
import { usePrompts } from "@/hooks/usePrompts";
|
||||
import { forwardRef } from "react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { MENTION_REGEX, parseAppMentions } from "@/shared/parse_mention_apps";
|
||||
|
||||
// Define the theme for mentions
|
||||
const beautifulMentionsTheme: BeautifulMentionsTheme = {
|
||||
"@": "px-2 py-0.5 mx-0.5 bg-accent text-accent-foreground rounded-md",
|
||||
"@Focused": "outline-none ring-2 ring-ring",
|
||||
};
|
||||
|
||||
// Custom menu item component
|
||||
const CustomMenuItem = forwardRef<
|
||||
HTMLLIElement,
|
||||
BeautifulMentionsMenuItemProps
|
||||
>(({ selected, item, ...props }, ref) => {
|
||||
const isPrompt = typeof item !== "string" && item.data?.type === "prompt";
|
||||
const label = isPrompt ? "Prompt" : "App";
|
||||
const value = typeof item === "string" ? item : (item as any)?.value;
|
||||
return (
|
||||
<li
|
||||
className={`m-0 flex items-center px-3 py-2 cursor-pointer whitespace-nowrap ${
|
||||
selected
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-popover text-popover-foreground hover:bg-accent/50"
|
||||
}`}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<div className="flex items-center space-x-2 min-w-0">
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs font-medium rounded-md flex-shrink-0 ${
|
||||
isPrompt
|
||||
? "bg-purple-500 text-white"
|
||||
: "bg-primary text-primary-foreground"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="truncate text-sm">{value}</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
// Custom menu component
|
||||
function CustomMenu({ loading: _loading, ...props }: any) {
|
||||
return (
|
||||
<ul
|
||||
className="m-0 mb-1 min-w-[300px] w-auto max-h-64 overflow-y-auto bg-popover border border-border rounded-lg shadow-lg z-50"
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "100%",
|
||||
left: 0,
|
||||
right: 0,
|
||||
transform: "translateY(-20px)", // Add a larger gap between menu and input (12px higher)
|
||||
}}
|
||||
data-mentions-menu="true"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Plugin to handle Enter key
|
||||
function EnterKeyPlugin({ onSubmit }: { onSubmit: () => void }) {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
KEY_ENTER_COMMAND,
|
||||
(event: KeyboardEvent) => {
|
||||
// Check if mentions menu is open by looking for our custom menu element
|
||||
const mentionsMenu = document.querySelector(
|
||||
'[data-mentions-menu="true"]',
|
||||
);
|
||||
const hasVisibleItems =
|
||||
mentionsMenu && mentionsMenu.children.length > 0;
|
||||
|
||||
if (hasVisibleItems) {
|
||||
// If mentions menu is open with items, let the mentions plugin handle the Enter key
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH, // Use higher priority to catch before mentions plugin
|
||||
);
|
||||
}, [editor, onSubmit]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Plugin to clear editor content
|
||||
function ClearEditorPlugin({
|
||||
shouldClear,
|
||||
onCleared,
|
||||
}: {
|
||||
shouldClear: boolean;
|
||||
onCleared: () => void;
|
||||
}) {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldClear) {
|
||||
editor.update(() => {
|
||||
const root = $getRoot();
|
||||
root.clear();
|
||||
const paragraph = $createParagraphNode();
|
||||
root.append(paragraph);
|
||||
paragraph.select();
|
||||
});
|
||||
onCleared();
|
||||
}
|
||||
}, [editor, shouldClear, onCleared]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Plugin to sync external value prop into the editor
|
||||
function ExternalValueSyncPlugin({
|
||||
value,
|
||||
promptsById,
|
||||
}: {
|
||||
value: string;
|
||||
promptsById: Record<number, string>;
|
||||
}) {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => {
|
||||
// Derive the display text that should appear in the editor (@Name) from the
|
||||
// internal value representation (@app:Name)
|
||||
let displayText = (value || "").replace(MENTION_REGEX, "@$1");
|
||||
displayText = displayText.replace(/@prompt:(\d+)/g, (_m, idStr) => {
|
||||
const id = Number(idStr);
|
||||
const title = promptsById[id];
|
||||
return title ? `@${title}` : _m;
|
||||
});
|
||||
|
||||
const currentText = editor.getEditorState().read(() => {
|
||||
const root = $getRoot();
|
||||
return root.getTextContent();
|
||||
});
|
||||
|
||||
// If the editor already reflects the same display text, do nothing to avoid loops
|
||||
if (currentText === displayText) return;
|
||||
editor.update(() => {
|
||||
const root = $getRoot();
|
||||
root.clear();
|
||||
|
||||
const paragraph = $createParagraphNode();
|
||||
|
||||
// Build nodes from internal value, turning @app:Name and @prompt:<id> into mention nodes
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
const combined = /@app:([a-zA-Z0-9_-]+)|@prompt:(\d+)/g;
|
||||
while ((match = combined.exec(value)) !== null) {
|
||||
const start = match.index;
|
||||
const full = match[0];
|
||||
if (start > lastIndex) {
|
||||
const textBefore = value.slice(lastIndex, start);
|
||||
if (textBefore) paragraph.append($createTextNode(textBefore));
|
||||
}
|
||||
if (match[1]) {
|
||||
const appName = match[1];
|
||||
paragraph.append($createBeautifulMentionNode("@", appName));
|
||||
} else if (match[2]) {
|
||||
const id = Number(match[2]);
|
||||
const title = promptsById[id] || `prompt:${id}`;
|
||||
paragraph.append($createBeautifulMentionNode("@", title));
|
||||
}
|
||||
lastIndex = start + full.length;
|
||||
}
|
||||
if (lastIndex < value.length) {
|
||||
const trailing = value.slice(lastIndex);
|
||||
if (trailing) paragraph.append($createTextNode(trailing));
|
||||
}
|
||||
|
||||
if (value && paragraph.getTextContent() === "") {
|
||||
paragraph.append($createTextNode(value));
|
||||
}
|
||||
|
||||
root.append(paragraph);
|
||||
paragraph.selectEnd();
|
||||
});
|
||||
}, [editor, value, promptsById]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface LexicalChatInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onPaste?: (e: React.ClipboardEvent) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
excludeCurrentApp: boolean;
|
||||
}
|
||||
|
||||
function onError(error: Error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
export function LexicalChatInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onPaste,
|
||||
excludeCurrentApp,
|
||||
placeholder = "Ask Dyad to build...",
|
||||
disabled = false,
|
||||
}: LexicalChatInputProps) {
|
||||
const { apps } = useLoadApps();
|
||||
const { prompts } = usePrompts();
|
||||
const [shouldClear, setShouldClear] = useState(false);
|
||||
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
||||
|
||||
// Prepare mention items - convert apps to mention format
|
||||
const mentionItems = React.useMemo(() => {
|
||||
if (!apps) return { "@": [] };
|
||||
|
||||
// Get current app name
|
||||
const currentApp = apps.find((app) => app.id === selectedAppId);
|
||||
const currentAppName = currentApp?.name;
|
||||
|
||||
// Parse already mentioned apps from current input value
|
||||
const alreadyMentioned = parseAppMentions(value);
|
||||
|
||||
// Filter out current app and already mentioned apps
|
||||
const filteredApps = apps.filter((app) => {
|
||||
// Exclude current app
|
||||
if (excludeCurrentApp && app.name === currentAppName) return false;
|
||||
|
||||
// Exclude already mentioned apps (case-insensitive comparison)
|
||||
if (
|
||||
alreadyMentioned.some(
|
||||
(mentioned) => mentioned.toLowerCase() === app.name.toLowerCase(),
|
||||
)
|
||||
)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const appMentions = filteredApps.map((app) => app.name);
|
||||
|
||||
const promptItems = (prompts || []).map((p) => ({
|
||||
value: p.title,
|
||||
type: "prompt",
|
||||
id: p.id,
|
||||
}));
|
||||
|
||||
return {
|
||||
"@": [...appMentions, ...promptItems],
|
||||
};
|
||||
}, [apps, selectedAppId, value, excludeCurrentApp, prompts]);
|
||||
|
||||
const initialConfig = {
|
||||
namespace: "ChatInput",
|
||||
theme: {
|
||||
beautifulMentions: beautifulMentionsTheme,
|
||||
},
|
||||
onError,
|
||||
nodes: [BeautifulMentionNode],
|
||||
editable: !disabled,
|
||||
};
|
||||
|
||||
const handleEditorChange = useCallback(
|
||||
(editorState: EditorState) => {
|
||||
editorState.read(() => {
|
||||
const root = $getRoot();
|
||||
let textContent = root.getTextContent();
|
||||
|
||||
// Transform @AppName mentions to @app:AppName format
|
||||
// This regex matches @AppName where AppName is one of our actual app names
|
||||
|
||||
// Short-circuit if there's no "@" symbol in the text
|
||||
if (textContent.includes("@")) {
|
||||
const appNames = apps?.map((app) => app.name) || [];
|
||||
for (const appName of appNames) {
|
||||
// Escape special regex characters in app name
|
||||
const escapedAppName = appName.replace(
|
||||
/[.*+?^${}()|[\]\\]/g,
|
||||
"\\$&",
|
||||
);
|
||||
const mentionRegex = new RegExp(
|
||||
`@(${escapedAppName})(?![a-zA-Z0-9_-])`,
|
||||
"g",
|
||||
);
|
||||
textContent = textContent.replace(mentionRegex, "@app:$1");
|
||||
}
|
||||
// Convert @PromptTitle to @prompt:<id>
|
||||
const map = new Map((prompts || []).map((p) => [p.title, p.id]));
|
||||
for (const [title, id] of map.entries()) {
|
||||
const escapedTitle = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`@(${escapedTitle})(?![\\w-])`, "g");
|
||||
textContent = textContent.replace(regex, `@prompt:${id}`);
|
||||
}
|
||||
}
|
||||
onChange(textContent);
|
||||
});
|
||||
},
|
||||
[onChange, apps, prompts],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
onSubmit();
|
||||
setShouldClear(true);
|
||||
}, [onSubmit]);
|
||||
|
||||
const handleCleared = useCallback(() => {
|
||||
setShouldClear(false);
|
||||
}, []);
|
||||
|
||||
// Update editor content when value changes externally (like clearing)
|
||||
useEffect(() => {
|
||||
if (value === "") {
|
||||
setShouldClear(true);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<div className="relative flex-1">
|
||||
<PlainTextPlugin
|
||||
contentEditable={
|
||||
<ContentEditable
|
||||
className="flex-1 p-2 focus:outline-none overflow-y-auto min-h-[40px] max-h-[200px] resize-none"
|
||||
aria-placeholder={placeholder}
|
||||
placeholder={
|
||||
<div className="absolute top-2 left-2 text-muted-foreground pointer-events-none select-none">
|
||||
{placeholder}
|
||||
</div>
|
||||
}
|
||||
onPaste={onPaste}
|
||||
/>
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<BeautifulMentionsPlugin
|
||||
items={mentionItems}
|
||||
menuComponent={CustomMenu}
|
||||
menuItemComponent={CustomMenuItem}
|
||||
creatable={false}
|
||||
insertOnBlur={false}
|
||||
menuItemLimit={10}
|
||||
/>
|
||||
<OnChangePlugin onChange={handleEditorChange} />
|
||||
<HistoryPlugin />
|
||||
<EnterKeyPlugin onSubmit={handleSubmit} />
|
||||
<ExternalValueSyncPlugin
|
||||
value={value}
|
||||
promptsById={Object.fromEntries(
|
||||
(prompts || []).map((p) => [p.id, p.title]),
|
||||
)}
|
||||
/>
|
||||
<ClearEditorPlugin
|
||||
shouldClear={shouldClear}
|
||||
onCleared={handleCleared}
|
||||
/>
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
);
|
||||
}
|
||||
236
src/components/chat/MessagesList.tsx
Normal file
236
src/components/chat/MessagesList.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import type React from "react";
|
||||
import type { Message } from "@/ipc/ipc_types";
|
||||
import { forwardRef, useState } from "react";
|
||||
import ChatMessage from "./ChatMessage";
|
||||
import { SetupBanner } from "../SetupBanner";
|
||||
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Loader2, RefreshCw, Undo } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useVersions } from "@/hooks/useVersions";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { showError, showWarning } from "@/lib/toast";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { chatMessagesAtom } from "@/atoms/chatAtoms";
|
||||
import { useLanguageModelProviders } from "@/hooks/useLanguageModelProviders";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
|
||||
import { PromoMessage } from "./PromoMessage";
|
||||
|
||||
interface MessagesListProps {
|
||||
messages: Message[];
|
||||
messagesEndRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
|
||||
function MessagesList({ messages, messagesEndRef }, ref) {
|
||||
const appId = useAtomValue(selectedAppIdAtom);
|
||||
const { versions, revertVersion } = useVersions(appId);
|
||||
const { streamMessage, isStreaming } = useStreamChat();
|
||||
const { isAnyProviderSetup } = useLanguageModelProviders();
|
||||
const { settings } = useSettings();
|
||||
const setMessages = useSetAtom(chatMessagesAtom);
|
||||
const [isUndoLoading, setIsUndoLoading] = useState(false);
|
||||
const [isRetryLoading, setIsRetryLoading] = useState(false);
|
||||
const selectedChatId = useAtomValue(selectedChatIdAtom);
|
||||
const { userBudget } = useUserBudgetInfo();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 overflow-y-auto p-4"
|
||||
ref={ref}
|
||||
data-testid="messages-list"
|
||||
>
|
||||
{messages.length > 0 ? (
|
||||
messages.map((message, index) => (
|
||||
<ChatMessage
|
||||
key={index}
|
||||
message={message}
|
||||
isLastMessage={index === messages.length - 1}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full max-w-2xl mx-auto">
|
||||
<div className="flex flex-1 items-center justify-center text-gray-500">
|
||||
No messages yet
|
||||
</div>
|
||||
{!isAnyProviderSetup() && <SetupBanner />}
|
||||
</div>
|
||||
)}
|
||||
{!isStreaming && (
|
||||
<div className="flex max-w-3xl mx-auto gap-2">
|
||||
{!!messages.length &&
|
||||
messages[messages.length - 1].role === "assistant" &&
|
||||
messages[messages.length - 1].commitHash && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isUndoLoading}
|
||||
onClick={async () => {
|
||||
if (!selectedChatId || !appId) {
|
||||
console.error("No chat selected or app ID not available");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUndoLoading(true);
|
||||
try {
|
||||
if (messages.length >= 3) {
|
||||
const previousAssistantMessage =
|
||||
messages[messages.length - 3];
|
||||
if (
|
||||
previousAssistantMessage?.role === "assistant" &&
|
||||
previousAssistantMessage?.commitHash
|
||||
) {
|
||||
console.debug(
|
||||
"Reverting to previous assistant version",
|
||||
);
|
||||
await revertVersion({
|
||||
versionId: previousAssistantMessage.commitHash,
|
||||
});
|
||||
const chat =
|
||||
await IpcClient.getInstance().getChat(
|
||||
selectedChatId,
|
||||
);
|
||||
setMessages(chat.messages);
|
||||
}
|
||||
} else {
|
||||
const chat =
|
||||
await IpcClient.getInstance().getChat(selectedChatId);
|
||||
if (chat.initialCommitHash) {
|
||||
await revertVersion({
|
||||
versionId: chat.initialCommitHash,
|
||||
});
|
||||
try {
|
||||
await IpcClient.getInstance().deleteMessages(
|
||||
selectedChatId,
|
||||
);
|
||||
setMessages([]);
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
} else {
|
||||
showWarning(
|
||||
"No initial commit hash found for chat. Need to manually undo code changes",
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during undo operation:", error);
|
||||
showError("Failed to undo changes");
|
||||
} finally {
|
||||
setIsUndoLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isUndoLoading ? (
|
||||
<Loader2 size={16} className="mr-1 animate-spin" />
|
||||
) : (
|
||||
<Undo size={16} />
|
||||
)}
|
||||
Undo
|
||||
</Button>
|
||||
)}
|
||||
{!!messages.length && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isRetryLoading}
|
||||
onClick={async () => {
|
||||
if (!selectedChatId) {
|
||||
console.error("No chat selected");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRetryLoading(true);
|
||||
try {
|
||||
// The last message is usually an assistant, but it might not be.
|
||||
const lastVersion = versions[0];
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
let shouldRedo = true;
|
||||
if (
|
||||
lastVersion.oid === lastMessage.commitHash &&
|
||||
lastMessage.role === "assistant"
|
||||
) {
|
||||
const previousAssistantMessage =
|
||||
messages[messages.length - 3];
|
||||
if (
|
||||
previousAssistantMessage?.role === "assistant" &&
|
||||
previousAssistantMessage?.commitHash
|
||||
) {
|
||||
console.debug(
|
||||
"Reverting to previous assistant version",
|
||||
);
|
||||
await revertVersion({
|
||||
versionId: previousAssistantMessage.commitHash,
|
||||
});
|
||||
shouldRedo = false;
|
||||
} else {
|
||||
const chat =
|
||||
await IpcClient.getInstance().getChat(selectedChatId);
|
||||
if (chat.initialCommitHash) {
|
||||
console.debug(
|
||||
"Reverting to initial commit hash",
|
||||
chat.initialCommitHash,
|
||||
);
|
||||
await revertVersion({
|
||||
versionId: chat.initialCommitHash,
|
||||
});
|
||||
} else {
|
||||
showWarning(
|
||||
"No initial commit hash found for chat. Need to manually undo code changes",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the last user message
|
||||
const lastUserMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === "user");
|
||||
if (!lastUserMessage) {
|
||||
console.error("No user message found");
|
||||
return;
|
||||
}
|
||||
// Need to do a redo, if we didn't delete the message from a revert.
|
||||
const redo = shouldRedo;
|
||||
console.debug("Streaming message with redo", redo);
|
||||
|
||||
streamMessage({
|
||||
prompt: lastUserMessage.content,
|
||||
chatId: selectedChatId,
|
||||
redo,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error during retry operation:", error);
|
||||
showError("Failed to retry message");
|
||||
} finally {
|
||||
setIsRetryLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isRetryLoading ? (
|
||||
<Loader2 size={16} className="mr-1 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={16} />
|
||||
)}
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isStreaming &&
|
||||
!settings?.enableDyadPro &&
|
||||
!userBudget &&
|
||||
messages.length > 0 && (
|
||||
<PromoMessage
|
||||
seed={messages.length * (appId ?? 1) * (selectedChatId ?? 1)}
|
||||
/>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user