Initial open-source release
This commit is contained in:
43
src/App.css
Normal file
43
src/App.css
Normal file
@@ -0,0 +1,43 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
11
src/App.tsx
Normal file
11
src/App.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import { router } from "./router";
|
||||
|
||||
// The router is automatically initialized by RouterProvider
|
||||
// so we don't need to call initialize() manually
|
||||
|
||||
function App() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
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.
|
||||
724
src/__tests__/chat_stream_handlers.test.ts
Normal file
724
src/__tests__/chat_stream_handlers.test.ts
Normal file
@@ -0,0 +1,724 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
getDyadWriteTags,
|
||||
getDyadRenameTags,
|
||||
getDyadDeleteTags,
|
||||
processFullResponseActions,
|
||||
} from "../ipc/processors/response_processor";
|
||||
import fs from "node:fs";
|
||||
import git from "isomorphic-git";
|
||||
import { db } from "../db";
|
||||
|
||||
// Mock fs with default export
|
||||
vi.mock("node:fs", async () => {
|
||||
return {
|
||||
default: {
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
existsSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
unlinkSync: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock isomorphic-git
|
||||
vi.mock("isomorphic-git", () => ({
|
||||
default: {
|
||||
add: vi.fn().mockResolvedValue(undefined),
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
commit: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock paths module to control getDyadAppPath
|
||||
vi.mock("../paths/paths", () => ({
|
||||
getDyadAppPath: vi.fn().mockImplementation((appPath) => {
|
||||
return `/mock/user/data/path/${appPath}`;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock db
|
||||
vi.mock("../db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
chats: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("getDyadWriteTags", () => {
|
||||
it("should return an empty array when no dyad-write tags are found", () => {
|
||||
const result = getDyadWriteTags("No dyad-write tags here");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return an array of dyad-write tags", () => {
|
||||
const result = getDyadWriteTags(
|
||||
`I'll create a simple todo list app using React, TypeScript, and shadcn/ui components. Let's get started!
|
||||
|
||||
First, I'll create the necessary files for our todo list application:
|
||||
|
||||
<dyad-write path="src/types/todo.ts" description="Creating types for our todo items">
|
||||
export interface Todo {
|
||||
id: string;
|
||||
text: string;
|
||||
completed: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
</dyad-write>
|
||||
|
||||
<dyad-write path="src/components/TodoItem.tsx" description="Creating a component for individual todo items">
|
||||
import React from "react";
|
||||
import { Trash2, Check, X } from "lucide-react";
|
||||
import { Todo } from "../types/todo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TodoItemProps {
|
||||
todo: Todo;
|
||||
onToggle: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
const TodoItem: React.FC<TodoItemProps> = ({ todo, onToggle, onDelete }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg mb-2 bg-white shadow-sm">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-6 w-6 rounded-full",
|
||||
todo.completed && "bg-green-100 text-green-600"
|
||||
)}
|
||||
onClick={() => onToggle(todo.id)}
|
||||
>
|
||||
{todo.completed ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<X className="h-4 w-4 opacity-0" />
|
||||
)}
|
||||
</Button>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
todo.completed && "line-through text-gray-500"
|
||||
)}
|
||||
>
|
||||
{todo.text}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => onDelete(todo.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TodoItem;
|
||||
</dyad-write>
|
||||
|
||||
<dyad-write path="src/components/TodoForm.tsx" description="Creating a form component for adding new todos">
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PlusCircle } from "lucide-react";
|
||||
|
||||
interface TodoFormProps {
|
||||
onAddTodo: (text: string) => void;
|
||||
}
|
||||
|
||||
const TodoForm: React.FC<TodoFormProps> = ({ onAddTodo }) => {
|
||||
const [text, setText] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (text.trim()) {
|
||||
onAddTodo(text.trim());
|
||||
setText("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex gap-2 mb-6">
|
||||
<Input
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Add a new task..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="submit" disabled={!text.trim()}>
|
||||
<PlusCircle className="h-4 w-4 mr-2" />
|
||||
Add
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default TodoForm;
|
||||
</dyad-write>
|
||||
|
||||
<dyad-write path="src/components/TodoList.tsx" description="Creating the main todo list component">
|
||||
import React from "react";
|
||||
import { Todo } from "../types/todo";
|
||||
import TodoItem from "./TodoItem";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
interface TodoListProps {
|
||||
todos: Todo[];
|
||||
onToggle: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
const TodoList: React.FC<TodoListProps> = ({ todos, onToggle, onDelete }) => {
|
||||
if (todos.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No tasks yet. Add one above!
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-[400px] rounded-md border p-4">
|
||||
<div className="space-y-2">
|
||||
{todos.map((todo) => (
|
||||
<TodoItem
|
||||
key={todo.id}
|
||||
todo={todo}
|
||||
onToggle={onToggle}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
export default TodoList;
|
||||
</dyad-write>
|
||||
|
||||
<dyad-write path="src/components/TodoStats.tsx" description="Creating a component to show todo statistics">
|
||||
import React from "react";
|
||||
import { Todo } from "../types/todo";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface TodoStatsProps {
|
||||
todos: Todo[];
|
||||
}
|
||||
|
||||
const TodoStats: React.FC<TodoStatsProps> = ({ todos }) => {
|
||||
const total = todos.length;
|
||||
const completed = todos.filter((todo) => todo.completed).length;
|
||||
const pending = total - completed;
|
||||
const percentComplete = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-gray-500">Total Tasks</p>
|
||||
<p className="text-3xl font-bold">{total}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-gray-500">Completed</p>
|
||||
<p className="text-3xl font-bold text-green-600">{completed}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-gray-500">Pending</p>
|
||||
<p className="text-3xl font-bold text-amber-600">{pending}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-3">
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Progress</span>
|
||||
<span>{percentComplete}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 transition-all duration-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TodoStats;
|
||||
</dyad-write>
|
||||
|
||||
<dyad-write path="src/pages/Index.tsx" description="Creating the main page for our todo app">
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { Todo } from "../types/todo";
|
||||
import TodoForm from "../components/TodoForm";
|
||||
import TodoList from "../components/TodoList";
|
||||
import TodoStats from "../components/TodoStats";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
const Index: React.FC = () => {
|
||||
const [todos, setTodos] = useState<Todo[]>(() => {
|
||||
const savedTodos = localStorage.getItem("todos");
|
||||
if (savedTodos) {
|
||||
try {
|
||||
// Parse the saved todos and convert string dates back to Date objects
|
||||
return JSON.parse(savedTodos).map((todo: any) => ({
|
||||
...todo,
|
||||
createdAt: new Date(todo.createdAt)
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error("Failed to parse saved todos", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
// Save todos to localStorage whenever they change
|
||||
useEffect(() => {
|
||||
localStorage.setItem("todos", JSON.stringify(todos));
|
||||
}, [todos]);
|
||||
|
||||
const handleAddTodo = (text: string) => {
|
||||
const newTodo: Todo = {
|
||||
id: uuidv4(),
|
||||
text,
|
||||
completed: false,
|
||||
createdAt: new Date()
|
||||
};
|
||||
|
||||
setTodos([...todos, newTodo]);
|
||||
toast({
|
||||
title: "Task added",
|
||||
description: "Your new task has been added successfully.",
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleTodo = (id: string) => {
|
||||
setTodos(
|
||||
todos.map((todo) =>
|
||||
todo.id === id ? { ...todo, completed: !todo.completed } : todo
|
||||
)
|
||||
);
|
||||
|
||||
const todo = todos.find(t => t.id === id);
|
||||
if (todo) {
|
||||
toast({
|
||||
title: todo.completed ? "Task marked as incomplete" : "Task completed",
|
||||
description: todo.text,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTodo = (id: string) => {
|
||||
const todoToDelete = todos.find(todo => todo.id === id);
|
||||
setTodos(todos.filter((todo) => todo.id !== id));
|
||||
|
||||
if (todoToDelete) {
|
||||
toast({
|
||||
title: "Task deleted",
|
||||
description: todoToDelete.text,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-3xl">
|
||||
<h1 className="text-3xl font-bold mb-2 text-center">Todo List</h1>
|
||||
<p className="text-gray-500 mb-8 text-center">
|
||||
Keep track of your tasks and stay organized
|
||||
</p>
|
||||
|
||||
<TodoStats todos={todos} />
|
||||
<TodoForm onAddTodo={handleAddTodo} />
|
||||
<TodoList
|
||||
todos={todos}
|
||||
onToggle={handleToggleTodo}
|
||||
onDelete={handleDeleteTodo}
|
||||
/>
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
</dyad-write>
|
||||
|
||||
<dyad-add-dependency package="uuid"></dyad-add-dependency>
|
||||
|
||||
<dyad-write path="src/types/uuid.d.ts" description="Adding type definitions for uuid">
|
||||
declare module 'uuid' {
|
||||
export function v4(): string;
|
||||
}
|
||||
</dyad-write>
|
||||
|
||||
I've created a complete todo list application with the ability to add, complete, and delete tasks. The app includes statistics and uses local storage to persist data.`
|
||||
);
|
||||
expect(result.length).toEqual(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDyadRenameTags", () => {
|
||||
it("should return an empty array when no dyad-rename tags are found", () => {
|
||||
const result = getDyadRenameTags("No dyad-rename tags here");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return an array of dyad-rename tags", () => {
|
||||
const result = getDyadRenameTags(
|
||||
`<dyad-rename from="src/components/UserProfile.jsx" to="src/components/ProfileCard.jsx" />
|
||||
<dyad-rename from="src/utils/helpers.js" to="src/utils/utils.js" />`
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
from: "src/components/UserProfile.jsx",
|
||||
to: "src/components/ProfileCard.jsx",
|
||||
},
|
||||
{ from: "src/utils/helpers.js", to: "src/utils/utils.js" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDyadDeleteTags", () => {
|
||||
it("should return an empty array when no dyad-delete tags are found", () => {
|
||||
const result = getDyadDeleteTags("No dyad-delete tags here");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return an array of dyad-delete paths", () => {
|
||||
const result = getDyadDeleteTags(
|
||||
`<dyad-delete path="src/components/Analytics.jsx" />
|
||||
<dyad-delete path="src/utils/unused.js" />`
|
||||
);
|
||||
expect(result).toEqual([
|
||||
"src/components/Analytics.jsx",
|
||||
"src/utils/unused.js",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("processFullResponse", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock db query response
|
||||
vi.mocked(db.query.chats.findFirst).mockResolvedValue({
|
||||
id: 1,
|
||||
appId: 1,
|
||||
title: "Test Chat",
|
||||
createdAt: new Date(),
|
||||
app: {
|
||||
id: 1,
|
||||
name: "Mock App",
|
||||
path: "mock-app-path",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
messages: [],
|
||||
} as any);
|
||||
|
||||
// Default mock for existsSync to return true
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("should return empty object when no dyad-write tags are found", async () => {
|
||||
const result = await processFullResponseActions(
|
||||
"No dyad-write tags here",
|
||||
1,
|
||||
{
|
||||
chatSummary: undefined,
|
||||
}
|
||||
);
|
||||
expect(result).toEqual({});
|
||||
expect(fs.mkdirSync).not.toHaveBeenCalled();
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should process dyad-write tags and create files", async () => {
|
||||
// Set up fs mocks to succeed
|
||||
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
|
||||
|
||||
const response = `<dyad-write path="src/file1.js">console.log('Hello');</dyad-write>`;
|
||||
|
||||
const result = await processFullResponseActions(response, 1, {
|
||||
chatSummary: undefined,
|
||||
});
|
||||
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith(
|
||||
"/mock/user/data/path/mock-app-path/src",
|
||||
{ recursive: true }
|
||||
);
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
"/mock/user/data/path/mock-app-path/src/file1.js",
|
||||
"console.log('Hello');"
|
||||
);
|
||||
expect(git.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filepath: "src/file1.js",
|
||||
})
|
||||
);
|
||||
expect(git.commit).toHaveBeenCalled();
|
||||
expect(result).toEqual({ updatedFiles: true });
|
||||
});
|
||||
|
||||
it("should handle file system errors gracefully", async () => {
|
||||
// Set up the mock to throw an error on mkdirSync
|
||||
vi.mocked(fs.mkdirSync).mockImplementationOnce(() => {
|
||||
throw new Error("Mock filesystem error");
|
||||
});
|
||||
|
||||
const response = `<dyad-write path="src/error-file.js">This will fail</dyad-write>`;
|
||||
|
||||
const result = await processFullResponseActions(response, 1, {
|
||||
chatSummary: undefined,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty("error");
|
||||
expect(result.error).toContain("Mock filesystem error");
|
||||
});
|
||||
|
||||
it("should process multiple dyad-write tags and commit all files", async () => {
|
||||
// Clear previous mock calls
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Set up fs mocks to succeed
|
||||
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
|
||||
|
||||
const response = `
|
||||
<dyad-write path="src/file1.js">console.log('First file');</dyad-write>
|
||||
<dyad-write path="src/utils/file2.js">export const add = (a, b) => a + b;</dyad-write>
|
||||
<dyad-write path="src/components/Button.tsx">
|
||||
import React from 'react';
|
||||
export const Button = ({ children }) => <button>{children}</button>;
|
||||
</dyad-write>
|
||||
`;
|
||||
|
||||
const result = await processFullResponseActions(response, 1, {
|
||||
chatSummary: undefined,
|
||||
});
|
||||
|
||||
// Check that directories were created for each file path
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith(
|
||||
"/mock/user/data/path/mock-app-path/src",
|
||||
{ recursive: true }
|
||||
);
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith(
|
||||
"/mock/user/data/path/mock-app-path/src/utils",
|
||||
{ recursive: true }
|
||||
);
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith(
|
||||
"/mock/user/data/path/mock-app-path/src/components",
|
||||
{ recursive: true }
|
||||
);
|
||||
|
||||
// Using toHaveBeenNthCalledWith to check each specific call
|
||||
expect(fs.writeFileSync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/mock/user/data/path/mock-app-path/src/file1.js",
|
||||
"console.log('First file');"
|
||||
);
|
||||
expect(fs.writeFileSync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/mock/user/data/path/mock-app-path/src/utils/file2.js",
|
||||
"export const add = (a, b) => a + b;"
|
||||
);
|
||||
expect(fs.writeFileSync).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"/mock/user/data/path/mock-app-path/src/components/Button.tsx",
|
||||
"\n import React from 'react';\n export const Button = ({ children }) => <button>{children}</button>;\n "
|
||||
);
|
||||
|
||||
// Verify git operations were called for each file
|
||||
expect(git.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filepath: "src/file1.js",
|
||||
})
|
||||
);
|
||||
expect(git.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filepath: "src/utils/file2.js",
|
||||
})
|
||||
);
|
||||
expect(git.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filepath: "src/components/Button.tsx",
|
||||
})
|
||||
);
|
||||
|
||||
// Verify commit was called once after all files were added
|
||||
expect(git.commit).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({ updatedFiles: true });
|
||||
});
|
||||
|
||||
it("should process dyad-rename tags and rename files", async () => {
|
||||
// Set up fs mocks to succeed
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
|
||||
vi.mocked(fs.renameSync).mockImplementation(() => undefined);
|
||||
|
||||
const response = `<dyad-rename from="src/components/OldComponent.jsx" to="src/components/NewComponent.jsx" />`;
|
||||
|
||||
const result = await processFullResponseActions(response, 1, {
|
||||
chatSummary: undefined,
|
||||
});
|
||||
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith(
|
||||
"/mock/user/data/path/mock-app-path/src/components",
|
||||
{ recursive: true }
|
||||
);
|
||||
expect(fs.renameSync).toHaveBeenCalledWith(
|
||||
"/mock/user/data/path/mock-app-path/src/components/OldComponent.jsx",
|
||||
"/mock/user/data/path/mock-app-path/src/components/NewComponent.jsx"
|
||||
);
|
||||
expect(git.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filepath: "src/components/NewComponent.jsx",
|
||||
})
|
||||
);
|
||||
expect(git.remove).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filepath: "src/components/OldComponent.jsx",
|
||||
})
|
||||
);
|
||||
expect(git.commit).toHaveBeenCalled();
|
||||
expect(result).toEqual({ updatedFiles: true });
|
||||
});
|
||||
|
||||
it("should handle non-existent files during rename gracefully", async () => {
|
||||
// Set up the mock to return false for existsSync
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const response = `<dyad-rename from="src/components/NonExistent.jsx" to="src/components/NewFile.jsx" />`;
|
||||
|
||||
const result = await processFullResponseActions(response, 1, {
|
||||
chatSummary: undefined,
|
||||
});
|
||||
|
||||
expect(fs.mkdirSync).toHaveBeenCalled();
|
||||
expect(fs.renameSync).not.toHaveBeenCalled();
|
||||
expect(git.commit).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("should process dyad-delete tags and delete files", async () => {
|
||||
// Set up fs mocks to succeed
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.unlinkSync).mockImplementation(() => undefined);
|
||||
|
||||
const response = `<dyad-delete path="src/components/Unused.jsx" />`;
|
||||
|
||||
const result = await processFullResponseActions(response, 1, {
|
||||
chatSummary: undefined,
|
||||
});
|
||||
|
||||
expect(fs.unlinkSync).toHaveBeenCalledWith(
|
||||
"/mock/user/data/path/mock-app-path/src/components/Unused.jsx"
|
||||
);
|
||||
expect(git.remove).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filepath: "src/components/Unused.jsx",
|
||||
})
|
||||
);
|
||||
expect(git.commit).toHaveBeenCalled();
|
||||
expect(result).toEqual({ updatedFiles: true });
|
||||
});
|
||||
|
||||
it("should handle non-existent files during delete gracefully", async () => {
|
||||
// Set up the mock to return false for existsSync
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const response = `<dyad-delete path="src/components/NonExistent.jsx" />`;
|
||||
|
||||
const result = await processFullResponseActions(response, 1, {
|
||||
chatSummary: undefined,
|
||||
});
|
||||
|
||||
expect(fs.unlinkSync).not.toHaveBeenCalled();
|
||||
expect(git.remove).not.toHaveBeenCalled();
|
||||
expect(git.commit).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("should process mixed operations (write, rename, delete) in one response", async () => {
|
||||
// Set up fs mocks to succeed
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
|
||||
vi.mocked(fs.renameSync).mockImplementation(() => undefined);
|
||||
vi.mocked(fs.unlinkSync).mockImplementation(() => undefined);
|
||||
|
||||
const response = `
|
||||
<dyad-write path="src/components/NewComponent.jsx">import React from 'react'; export default () => <div>New</div>;</dyad-write>
|
||||
<dyad-rename from="src/components/OldComponent.jsx" to="src/components/RenamedComponent.jsx" />
|
||||
<dyad-delete path="src/components/Unused.jsx" />
|
||||
`;
|
||||
|
||||
const result = await processFullResponseActions(response, 1, {
|
||||
chatSummary: undefined,
|
||||
});
|
||||
|
||||
// Check write operation happened
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
"/mock/user/data/path/mock-app-path/src/components/NewComponent.jsx",
|
||||
"import React from 'react'; export default () => <div>New</div>;"
|
||||
);
|
||||
|
||||
// Check rename operation happened
|
||||
expect(fs.renameSync).toHaveBeenCalledWith(
|
||||
"/mock/user/data/path/mock-app-path/src/components/OldComponent.jsx",
|
||||
"/mock/user/data/path/mock-app-path/src/components/RenamedComponent.jsx"
|
||||
);
|
||||
|
||||
// Check delete operation happened
|
||||
expect(fs.unlinkSync).toHaveBeenCalledWith(
|
||||
"/mock/user/data/path/mock-app-path/src/components/Unused.jsx"
|
||||
);
|
||||
|
||||
// Check git operations
|
||||
expect(git.add).toHaveBeenCalledTimes(2); // For the write and rename
|
||||
expect(git.remove).toHaveBeenCalledTimes(2); // For the rename and delete
|
||||
|
||||
// Check the commit message includes all operations
|
||||
expect(git.commit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining(
|
||||
"wrote 1 file(s), renamed 1 file(s), deleted 1 file(s)"
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
expect(result).toEqual({ updatedFiles: true });
|
||||
});
|
||||
});
|
||||
25
src/app/TitleBar.tsx
Normal file
25
src/app/TitleBar.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { useLoadApps } from "@/hooks/useLoadApps";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
|
||||
export const TitleBar = () => {
|
||||
const [selectedAppId] = useAtom(selectedAppIdAtom);
|
||||
const { apps } = useLoadApps();
|
||||
const { navigate } = useRouter();
|
||||
|
||||
// Get selected app name
|
||||
const selectedApp = apps.find((app) => app.id === selectedAppId);
|
||||
const displayText = selectedApp
|
||||
? `App: ${selectedApp.name}`
|
||||
: "(no app selected)";
|
||||
|
||||
return (
|
||||
<div className="z-11 w-full h-8 bg-(--sidebar) absolute top-0 left-0 app-region-drag flex items-center">
|
||||
<div className="no-app-region-drag pl-24 text-sm font-medium">
|
||||
{displayText}
|
||||
</div>
|
||||
<div className="flex-1 text-center text-sm font-medium">Dyad</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
26
src/app/layout.tsx
Normal file
26
src/app/layout.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { ThemeProvider } from "../contexts/ThemeContext";
|
||||
import { Toaster } from "sonner";
|
||||
import { TitleBar } from "./TitleBar";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<TitleBar />
|
||||
<ThemeProvider>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<div className="flex h-screenish w-full overflow-x-hidden mt-8 mb-4 mr-4 border-t border-l border-border rounded-lg bg-background">
|
||||
{children}
|
||||
</div>
|
||||
<Toaster richColors />
|
||||
</SidebarProvider>
|
||||
</ThemeProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
19
src/atoms/appAtoms.ts
Normal file
19
src/atoms/appAtoms.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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">("preview");
|
||||
export const selectedVersionIdAtom = atom<string | null>(null);
|
||||
export const appOutputAtom = atom<AppOutput[]>([]);
|
||||
export const appUrlAtom = atom<
|
||||
{ appUrl: string; appId: number } | { appUrl: null; appId: null }
|
||||
>({ appUrl: null, appId: null });
|
||||
export const userSettingsAtom = atom<UserSettings | null>(null);
|
||||
|
||||
// Atom for storing allow-listed environment variables
|
||||
export const envVarsAtom = atom<Record<string, string | undefined>>({});
|
||||
20
src/atoms/chatAtoms.ts
Normal file
20
src/atoms/chatAtoms.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Message } from "ai";
|
||||
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>("");
|
||||
|
||||
// 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);
|
||||
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);
|
||||
95
src/components/AppList.tsx
Normal file
95
src/components/AppList.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
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">
|
||||
{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"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
140
src/components/ChatList.tsx
Normal file
140
src/components/ChatList.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
import type { ChatSummary } from "@/lib/schemas";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { PlusCircle } from "lucide-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { showError } from "@/lib/toast";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useChats } from "@/hooks/useChats";
|
||||
|
||||
export function ChatList({ show }: { show?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom);
|
||||
const [selectedAppId, setSelectedAppId] = useAtom(selectedAppIdAtom);
|
||||
const { chats, loading, refreshChats } = useChats(selectedAppId);
|
||||
const routerState = useRouterState();
|
||||
const isChatRoute = routerState.location.pathname === "/chat";
|
||||
|
||||
// Update selectedChatId when route changes
|
||||
useEffect(() => {
|
||||
if (isChatRoute) {
|
||||
const id = routerState.location.search.id;
|
||||
if (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: "/" });
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
handleChatClick({ chatId: chat.id, appId: chat.appId })
|
||||
}
|
||||
className={`justify-start w-full text-left py-3 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>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
)}
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
163
src/components/ChatPanel.tsx
Normal file
163
src/components/ChatPanel.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
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 { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
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 appId = useAtomValue(selectedAppIdAtom);
|
||||
const [messages, setMessages] = useAtom(chatMessagesAtom);
|
||||
const [appName, setAppName] = useState<string>("Chat");
|
||||
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 processedPromptRef = useRef<string | null>(null);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAppName = async () => {
|
||||
if (!appId) return;
|
||||
|
||||
try {
|
||||
const app = await IpcClient.getInstance().getApp(appId);
|
||||
if (app?.name) {
|
||||
setAppName(app.name);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch app name:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAppName();
|
||||
}, [appId]);
|
||||
|
||||
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
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
93
src/components/ModelPicker.tsx
Normal file
93
src/components/ModelPicker.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { LargeLanguageModel, ModelProvider } from "@/lib/schemas";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useState } from "react";
|
||||
import { MODEL_OPTIONS } from "@/constants/models";
|
||||
|
||||
interface ModelPickerProps {
|
||||
selectedModel: LargeLanguageModel;
|
||||
onModelSelect: (model: LargeLanguageModel) => void;
|
||||
}
|
||||
|
||||
export function ModelPicker({
|
||||
selectedModel,
|
||||
onModelSelect,
|
||||
}: ModelPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const modelDisplayName = MODEL_OPTIONS[selectedModel.provider].find(
|
||||
(model) => model.name === selectedModel.name
|
||||
)?.displayName;
|
||||
|
||||
// Flatten the model options into a single array with provider information
|
||||
const allModels = Object.entries(MODEL_OPTIONS).flatMap(
|
||||
([provider, models]) =>
|
||||
models.map((model) => ({
|
||||
...model,
|
||||
provider: provider as ModelProvider,
|
||||
}))
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 h-8"
|
||||
>
|
||||
<span>
|
||||
<span className="text-xs text-muted-foreground">Model:</span>{" "}
|
||||
{modelDisplayName}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-56 p-1" align="start">
|
||||
<div className="grid gap-2">
|
||||
{allModels.map((model) => (
|
||||
<Tooltip key={model.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={
|
||||
selectedModel.name === model.name ? "secondary" : "ghost"
|
||||
}
|
||||
className="w-full justify-start font-normal"
|
||||
onClick={() => {
|
||||
onModelSelect({
|
||||
name: model.name,
|
||||
provider: model.provider,
|
||||
});
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<span className="flex flex-col items-start">
|
||||
<span>{model.displayName}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{model.provider}
|
||||
</span>
|
||||
</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>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{model.description}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
75
src/components/ProviderSettings.tsx
Normal file
75
src/components/ProviderSettings.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { PROVIDERS } from "@/constants/models";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { providerSettingsRoute } from "@/routes/settings/providers/$provider";
|
||||
import type { ModelProvider } from "@/lib/schemas";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { GiftIcon } from "lucide-react";
|
||||
interface ProviderSettingsProps {
|
||||
configuredProviders?: ModelProvider[];
|
||||
}
|
||||
|
||||
export function ProviderSettingsGrid({
|
||||
configuredProviders = [],
|
||||
}: ProviderSettingsProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleProviderClick = (provider: ModelProvider) => {
|
||||
console.log("PROVIDER", provider);
|
||||
navigate({
|
||||
to: providerSettingsRoute.id,
|
||||
params: { provider },
|
||||
});
|
||||
};
|
||||
|
||||
const { isProviderSetup } = useSettings();
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-2xl font-bold mb-6">AI Providers</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Object.entries(PROVIDERS).map(([key, provider]) => {
|
||||
const isConfigured = configuredProviders.includes(
|
||||
key as ModelProvider
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={key}
|
||||
className="cursor-pointer transition-all hover:shadow-md border-border"
|
||||
onClick={() => handleProviderClick(key as ModelProvider)}
|
||||
>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-xl flex items-center justify-between">
|
||||
{provider.displayName}
|
||||
{isProviderSetup(key) ? (
|
||||
<span className="ml-3 text-sm font-medium text-green-500 bg-green-50 dark:bg-green-900/30 border border-green-500/50 dark:border-green-500/50 px-2 py-1 rounded-full">
|
||||
Ready
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500 bg-gray-50 dark:bg-gray-900 dark:text-gray-300 px-2 py-1 rounded-full">
|
||||
Needs Setup
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{provider.hasFreeTier && (
|
||||
<span className="text-blue-600 mt-2 dark:text-blue-400 text-sm font-medium bg-blue-100 dark:bg-blue-900/30 px-2 py-1 rounded-full inline-flex items-center">
|
||||
<GiftIcon className="w-4 h-4 mr-1" />
|
||||
Free tier available
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
src/components/SetupBanner.tsx
Normal file
39
src/components/SetupBanner.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { ChevronRight, GiftIcon, Sparkles } from "lucide-react";
|
||||
import { providerSettingsRoute } from "@/routes/settings/providers/$provider";
|
||||
|
||||
export function SetupBanner() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSetupClick = () => {
|
||||
navigate({
|
||||
to: providerSettingsRoute.id,
|
||||
params: { provider: "google" },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full mb-8 p-4 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-xl shadow-sm cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors"
|
||||
onClick={handleSetupClick}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-blue-100 dark:bg-blue-800 p-2 rounded-full">
|
||||
<Sparkles className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-blue-800 dark:text-blue-300">
|
||||
Setup your AI API access
|
||||
</h3>
|
||||
<p className="text-sm text-blue-600 dark:text-blue-400 flex items-center gap-1">
|
||||
<GiftIcon className="w-3.5 h-3.5" />
|
||||
Use Google Gemini for free
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
src/components/app-sidebar.tsx
Normal file
174
src/components/app-sidebar.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { Home, Inbox, Settings } 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 {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { ChatList } from "./ChatList";
|
||||
import { AppList } from "./AppList";
|
||||
|
||||
// Menu items.
|
||||
const items = [
|
||||
{
|
||||
title: "Apps",
|
||||
to: "/",
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
title: "Chat",
|
||||
to: "/chat",
|
||||
icon: Inbox,
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
to: "/settings",
|
||||
icon: Settings,
|
||||
},
|
||||
];
|
||||
|
||||
// Hover state types
|
||||
type HoverState =
|
||||
| "start-hover:app"
|
||||
| "start-hover:chat"
|
||||
| "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);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(hoverState === "start-hover:app" || hoverState === "start-hover:chat") &&
|
||||
state === "collapsed"
|
||||
) {
|
||||
expandedByHover.current = true;
|
||||
toggleSidebar();
|
||||
}
|
||||
if (
|
||||
hoverState === "clear-hover" &&
|
||||
state === "expanded" &&
|
||||
expandedByHover.current
|
||||
) {
|
||||
toggleSidebar();
|
||||
expandedByHover.current = false;
|
||||
setHoverState("no-hover");
|
||||
}
|
||||
}, [hoverState, toggleSidebar, state, setHoverState]);
|
||||
|
||||
const routerState = useRouterState();
|
||||
const isAppRoute =
|
||||
routerState.location.pathname === "/" ||
|
||||
routerState.location.pathname.startsWith("/app-details");
|
||||
const isChatRoute = routerState.location.pathname === "/chat";
|
||||
|
||||
let selectedItem: string | null = null;
|
||||
if (hoverState === "start-hover:app") {
|
||||
selectedItem = "Apps";
|
||||
} else if (hoverState === "start-hover:chat") {
|
||||
selectedItem = "Chat";
|
||||
} else if (state === "expanded") {
|
||||
if (isAppRoute) {
|
||||
selectedItem = "Apps";
|
||||
} else if (isChatRoute) {
|
||||
selectedItem = "Chat";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
collapsible="icon"
|
||||
onMouseLeave={() => {
|
||||
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"} />
|
||||
</div>
|
||||
</div>
|
||||
</SidebarContent>
|
||||
|
||||
<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");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
30
src/components/chat/ChatError.tsx
Normal file
30
src/components/chat/ChatError.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
88
src/components/chat/ChatHeader.tsx
Normal file
88
src/components/chat/ChatHeader.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { PanelRightOpen, History, PlusCircle } from "lucide-react";
|
||||
import { PanelRightClose } from "lucide-react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { useLoadVersions } from "@/hooks/useLoadVersions";
|
||||
import { Button } from "../ui/button";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||
import { useChats } from "@/hooks/useChats";
|
||||
import { showError } from "@/lib/toast";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
isPreviewOpen: boolean;
|
||||
onTogglePreview: () => void;
|
||||
onVersionClick: () => void;
|
||||
}
|
||||
|
||||
export function ChatHeader({
|
||||
isPreviewOpen,
|
||||
onTogglePreview,
|
||||
onVersionClick,
|
||||
}: ChatHeaderProps) {
|
||||
const appId = useAtomValue(selectedAppIdAtom);
|
||||
const { versions, loading } = useLoadVersions(appId);
|
||||
const { navigate } = useRouter();
|
||||
const setSelectedChatId = useSetAtom(selectedChatIdAtom);
|
||||
const { refreshChats } = useChats(appId);
|
||||
|
||||
const handleNewChat = async () => {
|
||||
// Only create a new chat if an app is selected
|
||||
if (appId) {
|
||||
try {
|
||||
// Create a new chat with an empty title for now
|
||||
const chatId = await IpcClient.getInstance().createChat(appId);
|
||||
|
||||
// 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: "/" });
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="@container flex items-center justify-between py-1.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} />
|
||||
{loading ? "..." : `Version ${versions.length}`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onTogglePreview}
|
||||
className="cursor-pointer p-2 hover:bg-(--background-lightest) rounded-md"
|
||||
>
|
||||
{isPreviewOpen ? (
|
||||
<PanelRightClose size={20} />
|
||||
) : (
|
||||
<PanelRightOpen size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
src/components/chat/ChatInput.tsx
Normal file
139
src/components/chat/ChatInput.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { SendIcon, StopCircleIcon, X } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ModelPicker } from "@/components/ModelPicker";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { chatInputValueAtom } from "@/atoms/chatAtoms";
|
||||
import { useAtom } from "jotai";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
import { useChats } from "@/hooks/useChats";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { useLoadApp } from "@/hooks/useLoadApp";
|
||||
|
||||
interface ChatInputProps {
|
||||
chatId?: number;
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInput({ chatId, onSubmit }: ChatInputProps) {
|
||||
const [inputValue, setInputValue] = useAtom(chatInputValueAtom);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const { streamMessage, isStreaming, setIsStreaming, error, setError } =
|
||||
useStreamChat();
|
||||
const [selectedAppId] = useAtom(selectedAppIdAtom);
|
||||
const [showError, setShowError] = useState(true);
|
||||
|
||||
const adjustHeight = () => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = "0px";
|
||||
const scrollHeight = textarea.scrollHeight;
|
||||
console.log("scrollHeight", scrollHeight);
|
||||
textarea.style.height = `${scrollHeight + 4}px`;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
adjustHeight();
|
||||
}, [inputValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setShowError(true);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submitHandler();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!inputValue.trim() || isStreaming || !chatId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentInput = inputValue;
|
||||
setInputValue("");
|
||||
await streamMessage({ prompt: currentInput, chatId });
|
||||
};
|
||||
const submitHandler = onSubmit ? onSubmit : handleSubmit;
|
||||
|
||||
const handleCancel = () => {
|
||||
if (chatId) {
|
||||
IpcClient.getInstance().cancelChatStream(chatId);
|
||||
}
|
||||
setIsStreaming(false);
|
||||
};
|
||||
|
||||
const dismissError = () => {
|
||||
setShowError(false);
|
||||
};
|
||||
|
||||
if (!settings) {
|
||||
return null; // Or loading state
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && showError && (
|
||||
<div className="relative mt-2 bg-red-50 border border-red-200 rounded-md shadow-sm p-2">
|
||||
<button
|
||||
onClick={dismissError}
|
||||
className="absolute top-1 left-1 p-1 hover:bg-red-100 rounded"
|
||||
>
|
||||
<X size={14} className="text-red-500" />
|
||||
</button>
|
||||
<div className="px-6 py-1 text-sm">
|
||||
<div className="text-red-700 text-wrap">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col space-y-2 border border-border rounded-lg bg-(--background-lighter) shadow-sm">
|
||||
<div className="flex items-start space-x-2 ">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Ask Dyad to build..."
|
||||
className="flex-1 p-2 focus:outline-none overflow-y-auto min-h-[40px] max-h-[200px]"
|
||||
style={{ resize: "none" }}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
{isStreaming ? (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-2 py-2 mt-1 mr-2 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg"
|
||||
title="Cancel generation"
|
||||
>
|
||||
<StopCircleIcon size={20} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={submitHandler}
|
||||
disabled={!inputValue.trim()}
|
||||
className="px-2 py-2 mt-1 mr-2 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg disabled:opacity-50"
|
||||
>
|
||||
<SendIcon size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-2 pb-2">
|
||||
<ModelPicker
|
||||
selectedModel={settings.selectedModel}
|
||||
onModelSelect={(model) =>
|
||||
updateSettings({ selectedModel: model })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
80
src/components/chat/ChatMessage.tsx
Normal file
80
src/components/chat/ChatMessage.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { memo } from "react";
|
||||
import type { Message } from "ai";
|
||||
import { DyadMarkdownParser } from "./DyadMarkdownParser";
|
||||
import { motion } from "framer-motion";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
const ChatMessage = memo(
|
||||
({ message }: ChatMessageProps) => {
|
||||
return (
|
||||
<div
|
||||
className={`flex ${
|
||||
message.role === "assistant" ? "justify-start" : "justify-end"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`rounded-lg p-2 mt-2 ${
|
||||
message.role === "assistant"
|
||||
? "w-full max-w-3xl mx-auto"
|
||||
: "bg-(--sidebar-accent)"
|
||||
}`}
|
||||
>
|
||||
{message.role === "assistant" && !message.content ? (
|
||||
<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"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<DyadMarkdownParser content={message.content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return prevProps.message.content === nextProps.message.content;
|
||||
}
|
||||
);
|
||||
|
||||
ChatMessage.displayName = "ChatMessage";
|
||||
|
||||
export default ChatMessage;
|
||||
89
src/components/chat/CodeHighlight.tsx
Normal file
89
src/components/chat/CodeHighlight.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { 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";
|
||||
|
||||
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;
|
||||
|
||||
// Get the current theme setting
|
||||
const { theme } = useTheme();
|
||||
|
||||
// State to track if dark mode is active
|
||||
const [isDarkMode, setIsDarkMode] = React.useState(false);
|
||||
|
||||
// Determine if dark mode is active when component mounts or theme changes
|
||||
useEffect(() => {
|
||||
const darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const updateTheme = () => {
|
||||
setIsDarkMode(
|
||||
theme === "dark" || (theme === "system" && darkModeQuery.matches)
|
||||
);
|
||||
};
|
||||
|
||||
updateTheme();
|
||||
darkModeQuery.addEventListener("change", updateTheme);
|
||||
|
||||
return () => {
|
||||
darkModeQuery.removeEventListener("change", updateTheme);
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
// 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-5"
|
||||
>
|
||||
{language ? (
|
||||
<span
|
||||
className="absolute right-3 top-2 text-xs tracking-tighter
|
||||
text-muted-foreground/85"
|
||||
>
|
||||
{language}
|
||||
</span>
|
||||
) : null}
|
||||
{displayedCode}
|
||||
</div>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return prevProps.children === nextProps.children;
|
||||
}
|
||||
);
|
||||
166
src/components/chat/DyadAddDependency.tsx
Normal file
166
src/components/chat/DyadAddDependency.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import type React from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import { IpcClient } from "../../ipc/ipc_client";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { chatMessagesAtom, selectedChatIdAtom } from "../../atoms/chatAtoms";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
import {
|
||||
Package,
|
||||
ChevronsUpDown,
|
||||
ChevronsDownUp,
|
||||
Loader,
|
||||
ExternalLink,
|
||||
Download,
|
||||
} 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(" ") || "";
|
||||
console.log("packages", packages);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const selectedChatId = useAtomValue(selectedChatIdAtom);
|
||||
const [messages, setMessages] = useAtom(chatMessagesAtom);
|
||||
const { streamMessage, isStreaming } = useStreamChat();
|
||||
const [isContentVisible, setIsContentVisible] = useState(false);
|
||||
const hasChildren = !!children;
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!packages || !selectedChatId) return;
|
||||
|
||||
setIsInstalling(true);
|
||||
setError(null);
|
||||
try {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
|
||||
await ipcClient.addDependency({
|
||||
chatId: selectedChatId,
|
||||
packages,
|
||||
});
|
||||
|
||||
// Refresh the chat messages
|
||||
const chat = await IpcClient.getInstance().getChat(selectedChatId);
|
||||
setMessages(chat.messages);
|
||||
|
||||
await streamMessage({
|
||||
prompt: `I've installed ${packages.join(", ")}. Keep going.`,
|
||||
chatId: selectedChatId,
|
||||
});
|
||||
} catch (err) {
|
||||
setError("There was an error installing this package.");
|
||||
|
||||
const chat = await IpcClient.getInstance().getChat(selectedChatId);
|
||||
setMessages(chat.messages);
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-(--background-lightest) dark:bg-gray-900 hover:bg-(--background-lighter) rounded-lg px-4 py-3 border my-2 ${
|
||||
hasChildren ? "cursor-pointer" : ""
|
||||
} ${isInstalling ? "border-amber-500" : "border-border"}`}
|
||||
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>
|
||||
)}
|
||||
{isInstalling && (
|
||||
<div className="flex items-center text-amber-600 text-xs ml-2">
|
||||
<Loader size={14} className="mr-1 animate-spin" />
|
||||
<span>Installing...</span>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Always show install button if there are no children */}
|
||||
{packages.length > 0 && !hasChildren && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
if (hasChildren) e.stopPropagation();
|
||||
handleInstall();
|
||||
}}
|
||||
disabled={isInstalling || isStreaming}
|
||||
size="default"
|
||||
variant="default"
|
||||
className="font-medium bg-primary/90 flex items-center gap-2 w-full max-w-sm py-4 mt-2 mb-2"
|
||||
>
|
||||
<Download size={16} />
|
||||
{isInstalling ? "Installing..." : "Install packages"}
|
||||
</Button>
|
||||
|
||||
{error && <div className="text-sm text-red-500 mt-2">{error}</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>
|
||||
);
|
||||
};
|
||||
285
src/components/chat/DyadMarkdownParser.tsx
Normal file
285
src/components/chat/DyadMarkdownParser.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import React, { useMemo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import { DyadWrite } from "./DyadWrite";
|
||||
import { DyadRename } from "./DyadRename";
|
||||
import { DyadDelete } from "./DyadDelete";
|
||||
import { DyadAddDependency } from "./DyadAddDependency";
|
||||
import { CodeHighlight } from "./CodeHighlight";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { isStreamingAtom } from "@/atoms/chatAtoms";
|
||||
import { CustomTagState } from "./stateTypes";
|
||||
|
||||
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 };
|
||||
|
||||
/**
|
||||
* 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
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
components={{ code: CodeHighlight } as any}
|
||||
>
|
||||
{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",
|
||||
];
|
||||
|
||||
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",
|
||||
];
|
||||
|
||||
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 "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>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract attribute values from className string
|
||||
*/
|
||||
function extractAttribute(className: string, attrName: string): string {
|
||||
const match = new RegExp(`${attrName}="([^"]*)"`, "g").exec(className);
|
||||
return match ? match[1] : "";
|
||||
}
|
||||
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>
|
||||
);
|
||||
};
|
||||
105
src/components/chat/DyadWrite.tsx
Normal file
105
src/components/chat/DyadWrite.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
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">
|
||||
<CodeHighlight className="language-typescript">
|
||||
{children}
|
||||
</CodeHighlight>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
71
src/components/chat/MessagesList.tsx
Normal file
71
src/components/chat/MessagesList.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type React from "react";
|
||||
import type { Message } from "ai";
|
||||
import { forwardRef } from "react";
|
||||
import ChatMessage from "./ChatMessage";
|
||||
import { SetupBanner } from "../SetupBanner";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface MessagesListProps {
|
||||
messages: Message[];
|
||||
messagesEndRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
|
||||
function MessagesList({ messages, messagesEndRef }, ref) {
|
||||
const { streamMessage, isStreaming, error, setError } = useStreamChat();
|
||||
const { isAnyProviderSetup } = useSettings();
|
||||
const selectedChatId = useAtomValue(selectedChatIdAtom);
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4" ref={ref}>
|
||||
{messages.length > 0 ? (
|
||||
messages.map((message, index) => (
|
||||
<ChatMessage key={index} message={message} />
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full max-w-2xl mx-auto">
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
No messages yet
|
||||
</div>
|
||||
{!isAnyProviderSetup() && <SetupBanner />}
|
||||
</div>
|
||||
)}
|
||||
{messages.length > 0 && !isStreaming && (
|
||||
<div className="flex max-w-3xl mx-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!selectedChatId) {
|
||||
console.error("No chat selected");
|
||||
return;
|
||||
}
|
||||
// Find the last user message
|
||||
const lastUserMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === "user");
|
||||
if (!lastUserMessage) {
|
||||
console.error("No user message found");
|
||||
return;
|
||||
}
|
||||
streamMessage({
|
||||
prompt: lastUserMessage.content,
|
||||
chatId: selectedChatId,
|
||||
redo: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
134
src/components/chat/VersionPane.tsx
Normal file
134
src/components/chat/VersionPane.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { selectedAppIdAtom, selectedVersionIdAtom } from "@/atoms/appAtoms";
|
||||
import { useLoadVersions } from "@/hooks/useLoadVersions";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { RotateCcw, X } from "lucide-react";
|
||||
import type { Version } from "@/ipc/ipc_types";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface VersionPaneProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
|
||||
const appId = useAtomValue(selectedAppIdAtom);
|
||||
const { versions, loading, refreshVersions } = useLoadVersions(appId);
|
||||
const [selectedVersionId, setSelectedVersionId] = useAtom(
|
||||
selectedVersionIdAtom
|
||||
);
|
||||
useEffect(() => {
|
||||
// Refresh versions in case the user updated versions outside of the app
|
||||
// (e.g. manually using git).
|
||||
// Avoid loading state which causes brief flash of loading state.
|
||||
refreshVersions();
|
||||
if (!isVisible && selectedVersionId) {
|
||||
setSelectedVersionId(null);
|
||||
IpcClient.getInstance().checkoutVersion({
|
||||
appId: appId!,
|
||||
versionId: "main",
|
||||
});
|
||||
}
|
||||
}, [isVisible, refreshVersions]);
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full border-t border-2 border-border w-full">
|
||||
<div className="p-2 border-b border-border flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold pl-2">Version History</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-(--background-lightest) rounded-md "
|
||||
aria-label="Close version pane"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto h-[calc(100%-60px)]">
|
||||
{loading ? (
|
||||
<div className="p-4 ">Loading versions...</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div className="p-4 ">No versions available</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{versions.map((version: Version, index) => (
|
||||
<div
|
||||
key={version.oid}
|
||||
className={`px-4 py-2 hover:bg-(--background-lightest) cursor-pointer ${
|
||||
selectedVersionId === version.oid
|
||||
? "bg-(--background-lightest)"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
IpcClient.getInstance().checkoutVersion({
|
||||
appId: appId!,
|
||||
versionId: version.oid,
|
||||
});
|
||||
setSelectedVersionId(version.oid);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-xs">
|
||||
Version {versions.length - index}
|
||||
</span>
|
||||
<span className="text-xs opacity-90">
|
||||
{formatDistanceToNow(new Date(version.timestamp * 1000), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{version.message && (
|
||||
<p className="mt-1 text-sm">
|
||||
{version.message.startsWith(
|
||||
"Reverted all changes back to version "
|
||||
)
|
||||
? version.message.replace(
|
||||
/Reverted all changes back to version ([a-f0-9]+)/,
|
||||
(_, hash) => {
|
||||
const targetIndex = versions.findIndex(
|
||||
(v) => v.oid === hash
|
||||
);
|
||||
return targetIndex !== -1
|
||||
? `Reverted all changes back to version ${
|
||||
versions.length - targetIndex
|
||||
}`
|
||||
: version.message;
|
||||
}
|
||||
)
|
||||
: version.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedVersionId(null);
|
||||
await IpcClient.getInstance().revertVersion({
|
||||
appId: appId!,
|
||||
previousVersionId: version.oid,
|
||||
});
|
||||
refreshVersions();
|
||||
}}
|
||||
className={cn(
|
||||
"invisible mt-1 flex items-center gap-1 px-2 py-0.5 text-sm font-medium bg-(--primary) text-(--primary-foreground) hover:bg-background-lightest rounded-md transition-colors",
|
||||
selectedVersionId === version.oid && "visible"
|
||||
)}
|
||||
aria-label="Undo to latest version"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
<span>Undo</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
src/components/chat/monaco.ts
Normal file
189
src/components/chat/monaco.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { editor } from "monaco-editor";
|
||||
|
||||
import { loader } from "@monaco-editor/react";
|
||||
|
||||
import * as monaco from "monaco-editor";
|
||||
// @ts-ignore
|
||||
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
|
||||
// @ts-ignore
|
||||
import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
|
||||
// @ts-ignore
|
||||
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
|
||||
// @ts-ignore
|
||||
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
|
||||
// @ts-ignore
|
||||
import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
|
||||
|
||||
self.MonacoEnvironment = {
|
||||
getWorker(_, label) {
|
||||
if (label === "json") {
|
||||
return new jsonWorker();
|
||||
}
|
||||
if (label === "css" || label === "scss" || label === "less") {
|
||||
return new cssWorker();
|
||||
}
|
||||
if (label === "html" || label === "handlebars" || label === "razor") {
|
||||
return new htmlWorker();
|
||||
}
|
||||
if (label === "typescript" || label === "javascript") {
|
||||
return new tsWorker();
|
||||
}
|
||||
return new editorWorker();
|
||||
},
|
||||
};
|
||||
|
||||
loader.config({ monaco });
|
||||
|
||||
// loader.init().then(/* ... */);
|
||||
export const customLight: editor.IStandaloneThemeData = {
|
||||
base: "vs",
|
||||
inherit: false,
|
||||
rules: [
|
||||
{ token: "", foreground: "000000", background: "fffffe" },
|
||||
{ token: "invalid", foreground: "cd3131" },
|
||||
{ token: "emphasis", fontStyle: "italic" },
|
||||
{ token: "strong", fontStyle: "bold" },
|
||||
|
||||
{ token: "variable", foreground: "001188" },
|
||||
{ token: "variable.predefined", foreground: "4864AA" },
|
||||
{ token: "constant", foreground: "dd0000" },
|
||||
{ token: "comment", foreground: "008000" },
|
||||
{ token: "number", foreground: "098658" },
|
||||
{ token: "number.hex", foreground: "3030c0" },
|
||||
{ token: "regexp", foreground: "800000" },
|
||||
{ token: "annotation", foreground: "808080" },
|
||||
{ token: "type", foreground: "008080" },
|
||||
|
||||
{ token: "delimiter", foreground: "000000" },
|
||||
{ token: "delimiter.html", foreground: "383838" },
|
||||
{ token: "delimiter.xml", foreground: "0000FF" },
|
||||
|
||||
{ token: "tag", foreground: "800000" },
|
||||
{ token: "tag.id.pug", foreground: "4F76AC" },
|
||||
{ token: "tag.class.pug", foreground: "4F76AC" },
|
||||
{ token: "meta.scss", foreground: "800000" },
|
||||
{ token: "metatag", foreground: "e00000" },
|
||||
{ token: "metatag.content.html", foreground: "FF0000" },
|
||||
{ token: "metatag.html", foreground: "808080" },
|
||||
{ token: "metatag.xml", foreground: "808080" },
|
||||
{ token: "metatag.php", fontStyle: "bold" },
|
||||
|
||||
{ token: "key", foreground: "863B00" },
|
||||
{ token: "string.key.json", foreground: "A31515" },
|
||||
{ token: "string.value.json", foreground: "0451A5" },
|
||||
|
||||
{ token: "attribute.name", foreground: "FF0000" },
|
||||
{ token: "attribute.value", foreground: "0451A5" },
|
||||
{ token: "attribute.value.number", foreground: "098658" },
|
||||
{ token: "attribute.value.unit", foreground: "098658" },
|
||||
{ token: "attribute.value.html", foreground: "0000FF" },
|
||||
{ token: "attribute.value.xml", foreground: "0000FF" },
|
||||
|
||||
{ token: "string", foreground: "A31515" },
|
||||
{ token: "string.html", foreground: "0000FF" },
|
||||
{ token: "string.sql", foreground: "FF0000" },
|
||||
{ token: "string.yaml", foreground: "0451A5" },
|
||||
|
||||
{ token: "keyword", foreground: "0000FF" },
|
||||
{ token: "keyword.json", foreground: "0451A5" },
|
||||
{ token: "keyword.flow", foreground: "AF00DB" },
|
||||
{ token: "keyword.flow.scss", foreground: "0000FF" },
|
||||
|
||||
{ token: "operator.scss", foreground: "666666" },
|
||||
{ token: "operator.sql", foreground: "778899" },
|
||||
{ token: "operator.swift", foreground: "666666" },
|
||||
{ token: "predefined.sql", foreground: "C700C7" },
|
||||
],
|
||||
colors: {
|
||||
// surface
|
||||
"editor.background": "#f7f5ff",
|
||||
"minimap.background": "#f7f5ff",
|
||||
"editor.foreground": "#000000",
|
||||
"editor.inactiveSelectionBackground": "#E5EBF1",
|
||||
"editorIndentGuide.background1": "#D3D3D3",
|
||||
"editorIndentGuide.activeBackground1": "#939393",
|
||||
"editor.selectionHighlightBackground": "#ADD6FF4D",
|
||||
},
|
||||
};
|
||||
|
||||
editor.defineTheme("dyad-light", customLight);
|
||||
|
||||
export const customDark: editor.IStandaloneThemeData = {
|
||||
base: "vs-dark",
|
||||
inherit: false,
|
||||
rules: [
|
||||
{ token: "", foreground: "D4D4D4", background: "1E1E1E" },
|
||||
{ token: "invalid", foreground: "f44747" },
|
||||
{ token: "emphasis", fontStyle: "italic" },
|
||||
{ token: "strong", fontStyle: "bold" },
|
||||
|
||||
{ token: "variable", foreground: "74B0DF" },
|
||||
{ token: "variable.predefined", foreground: "4864AA" },
|
||||
{ token: "variable.parameter", foreground: "9CDCFE" },
|
||||
{ token: "constant", foreground: "569CD6" },
|
||||
{ token: "comment", foreground: "608B4E" },
|
||||
{ token: "number", foreground: "B5CEA8" },
|
||||
{ token: "number.hex", foreground: "5BB498" },
|
||||
{ token: "regexp", foreground: "B46695" },
|
||||
{ token: "annotation", foreground: "cc6666" },
|
||||
{ token: "type", foreground: "3DC9B0" },
|
||||
|
||||
{ token: "delimiter", foreground: "DCDCDC" },
|
||||
{ token: "delimiter.html", foreground: "808080" },
|
||||
{ token: "delimiter.xml", foreground: "808080" },
|
||||
|
||||
{ token: "tag", foreground: "569CD6" },
|
||||
{ token: "tag.id.pug", foreground: "4F76AC" },
|
||||
{ token: "tag.class.pug", foreground: "4F76AC" },
|
||||
{ token: "meta.scss", foreground: "A79873" },
|
||||
{ token: "meta.tag", foreground: "CE9178" },
|
||||
{ token: "metatag", foreground: "DD6A6F" },
|
||||
{ token: "metatag.content.html", foreground: "9CDCFE" },
|
||||
{ token: "metatag.html", foreground: "569CD6" },
|
||||
{ token: "metatag.xml", foreground: "569CD6" },
|
||||
{ token: "metatag.php", fontStyle: "bold" },
|
||||
|
||||
{ token: "key", foreground: "9CDCFE" },
|
||||
{ token: "string.key.json", foreground: "9CDCFE" },
|
||||
{ token: "string.value.json", foreground: "CE9178" },
|
||||
|
||||
{ token: "attribute.name", foreground: "9CDCFE" },
|
||||
{ token: "attribute.value", foreground: "CE9178" },
|
||||
{ token: "attribute.value.number.css", foreground: "B5CEA8" },
|
||||
{ token: "attribute.value.unit.css", foreground: "B5CEA8" },
|
||||
{ token: "attribute.value.hex.css", foreground: "D4D4D4" },
|
||||
|
||||
{ token: "string", foreground: "CE9178" },
|
||||
{ token: "string.sql", foreground: "FF0000" },
|
||||
|
||||
{ token: "keyword", foreground: "569CD6" },
|
||||
{ token: "keyword.flow", foreground: "C586C0" },
|
||||
{ token: "keyword.json", foreground: "CE9178" },
|
||||
{ token: "keyword.flow.scss", foreground: "569CD6" },
|
||||
|
||||
{ token: "operator.scss", foreground: "909090" },
|
||||
{ token: "operator.sql", foreground: "778899" },
|
||||
{ token: "operator.swift", foreground: "909090" },
|
||||
{ token: "predefined.sql", foreground: "FF00FF" },
|
||||
],
|
||||
colors: {
|
||||
// surface
|
||||
"editor.background": "#131316",
|
||||
"minimap.background": "#131316",
|
||||
"editor.foreground": "#D4D4D4",
|
||||
"editor.inactiveSelectionBackground": "#3A3D41",
|
||||
"editorIndentGuide.background1": "#404040",
|
||||
"editorIndentGuide.activeBackground1": "#707070",
|
||||
"editor.selectionHighlightBackground": "#ADD6FF26",
|
||||
},
|
||||
};
|
||||
|
||||
editor.defineTheme("dyad-dark", customDark);
|
||||
|
||||
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
|
||||
jsx: monaco.languages.typescript.JsxEmit.React, // Enable JSX
|
||||
});
|
||||
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
|
||||
// Too noisy because we don't have the full TS environment.
|
||||
noSemanticValidation: true,
|
||||
});
|
||||
1
src/components/chat/stateTypes.ts
Normal file
1
src/components/chat/stateTypes.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type CustomTagState = "pending" | "finished" | "aborted";
|
||||
30
src/components/chat/types.d.ts
vendored
Normal file
30
src/components/chat/types.d.ts
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Components as ReactMarkdownComponents } from "react-markdown";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// Extend the ReactMarkdown Components type to include our custom components
|
||||
declare module "react-markdown" {
|
||||
interface Components extends ReactMarkdownComponents {
|
||||
"dyad-write"?: (props: {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
path?: string;
|
||||
description?: string;
|
||||
}) => JSX.Element;
|
||||
"dyad-rename"?: (props: {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}) => JSX.Element;
|
||||
"dyad-delete"?: (props: {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
path?: string;
|
||||
}) => JSX.Element;
|
||||
"dyad-add-dependency"?: (props: {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
package?: string;
|
||||
}) => JSX.Element;
|
||||
}
|
||||
}
|
||||
79
src/components/preview_panel/CodeView.tsx
Normal file
79
src/components/preview_panel/CodeView.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useState } from "react";
|
||||
import { FileEditor } from "./FileEditor";
|
||||
import { FileTree } from "./FileTree";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useLoadApp } from "@/hooks/useLoadApp";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { selectedFileAtom } from "@/atoms/viewAtoms";
|
||||
|
||||
interface App {
|
||||
id?: number;
|
||||
files?: string[];
|
||||
}
|
||||
|
||||
export interface CodeViewProps {
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
app: App | null;
|
||||
}
|
||||
|
||||
// Code view component that displays app files or status messages
|
||||
export const CodeView = ({ loading, error, app }: CodeViewProps) => {
|
||||
const selectedFile = useAtomValue(selectedFileAtom);
|
||||
const { refreshApp } = useLoadApp(app?.id ?? null);
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-4">Loading files...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-4 text-red-500">
|
||||
Error loading files: {error.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!app) {
|
||||
return (
|
||||
<div className="text-center py-4 text-gray-500">No app selected</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (app.files && app.files.length > 0) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center p-2 border-b space-x-2">
|
||||
<button
|
||||
onClick={refreshApp}
|
||||
className="p-1 rounded hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={loading || !app.id}
|
||||
title="Refresh Files"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
<div className="text-sm text-gray-500">{app.files.length} files</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="w-1/3 overflow-auto border-r">
|
||||
<FileTree files={app.files} />
|
||||
</div>
|
||||
<div className="w-2/3">
|
||||
{selectedFile ? (
|
||||
<FileEditor appId={app.id ?? null} filePath={selectedFile.path} />
|
||||
) : (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
Select a file to view
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="text-center py-4 text-gray-500">No files found</div>;
|
||||
};
|
||||
14
src/components/preview_panel/Console.tsx
Normal file
14
src/components/preview_panel/Console.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { appOutputAtom } from "@/atoms/appAtoms";
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
// Console component
|
||||
export const Console = () => {
|
||||
const appOutput = useAtomValue(appOutputAtom);
|
||||
return (
|
||||
<div className="font-mono text-xs px-4 h-full overflow-auto">
|
||||
{appOutput.map((output, index) => (
|
||||
<div key={index}>{output.message}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
205
src/components/preview_panel/FileEditor.tsx
Normal file
205
src/components/preview_panel/FileEditor.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import Editor, { OnMount } from "@monaco-editor/react";
|
||||
import { useLoadAppFile } from "@/hooks/useLoadAppFile";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { ChevronRight, Circle } from "lucide-react";
|
||||
import "@/components/chat/monaco";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
|
||||
interface FileEditorProps {
|
||||
appId: number | null;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbProps {
|
||||
path: string;
|
||||
hasUnsavedChanges: boolean;
|
||||
}
|
||||
|
||||
const Breadcrumb: React.FC<BreadcrumbProps> = ({ path, hasUnsavedChanges }) => {
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-2 text-sm text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-1 overflow-hidden">
|
||||
<div className="flex items-center gap-1 overflow-hidden min-w-0">
|
||||
{segments.map((segment, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{index > 0 && (
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="text-gray-400 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="hover:text-gray-900 dark:hover:text-gray-100 cursor-pointer truncate">
|
||||
{segment}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-2">
|
||||
{hasUnsavedChanges && (
|
||||
<Circle
|
||||
size={8}
|
||||
fill="currentColor"
|
||||
className="text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const FileEditor = ({ appId, filePath }: FileEditorProps) => {
|
||||
const { content, loading, error } = useLoadAppFile(appId, filePath);
|
||||
const { theme } = useTheme();
|
||||
const [value, setValue] = useState<string | undefined>(undefined);
|
||||
const [displayUnsavedChanges, setDisplayUnsavedChanges] = useState(false);
|
||||
|
||||
// Use refs for values that need to be current in event handlers
|
||||
const originalValueRef = useRef<string | undefined>(undefined);
|
||||
const editorRef = useRef<any>(null);
|
||||
const isSavingRef = useRef<boolean>(false);
|
||||
const needsSaveRef = useRef<boolean>(false);
|
||||
const currentValueRef = useRef<string | undefined>(undefined);
|
||||
|
||||
// Update state when content loads
|
||||
useEffect(() => {
|
||||
if (content !== null) {
|
||||
setValue(content);
|
||||
originalValueRef.current = content;
|
||||
currentValueRef.current = content;
|
||||
needsSaveRef.current = false;
|
||||
setDisplayUnsavedChanges(false);
|
||||
}
|
||||
}, [content, filePath]);
|
||||
|
||||
// Sync the UI with the needsSave ref
|
||||
useEffect(() => {
|
||||
setDisplayUnsavedChanges(needsSaveRef.current);
|
||||
}, [needsSaveRef.current]);
|
||||
|
||||
// Determine if dark mode based on theme
|
||||
const isDarkMode =
|
||||
theme === "dark" ||
|
||||
(theme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const editorTheme = isDarkMode ? "dyad-dark" : "dyad-light";
|
||||
|
||||
// Handle editor mount
|
||||
const handleEditorDidMount: OnMount = (editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
|
||||
// Listen for model content change events
|
||||
editor.onDidBlurEditorText(() => {
|
||||
console.log("Editor text blurred, checking if save needed");
|
||||
if (needsSaveRef.current) {
|
||||
saveFile();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Handle content change
|
||||
const handleEditorChange = (newValue: string | undefined) => {
|
||||
setValue(newValue);
|
||||
currentValueRef.current = newValue;
|
||||
|
||||
const hasChanged = newValue !== originalValueRef.current;
|
||||
needsSaveRef.current = hasChanged;
|
||||
setDisplayUnsavedChanges(hasChanged);
|
||||
};
|
||||
|
||||
// Save the file
|
||||
const saveFile = async () => {
|
||||
if (
|
||||
!appId ||
|
||||
!currentValueRef.current ||
|
||||
!needsSaveRef.current ||
|
||||
isSavingRef.current
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
isSavingRef.current = true;
|
||||
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
await ipcClient.editAppFile(appId, filePath, currentValueRef.current);
|
||||
|
||||
originalValueRef.current = currentValueRef.current;
|
||||
needsSaveRef.current = false;
|
||||
setDisplayUnsavedChanges(false);
|
||||
} catch (error) {
|
||||
console.error("Error saving file:", error);
|
||||
// Could add error notification here
|
||||
} finally {
|
||||
isSavingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Determine language based on file extension
|
||||
const getLanguage = (filePath: string) => {
|
||||
const extension = filePath.split(".").pop()?.toLowerCase() || "";
|
||||
const languageMap: Record<string, string> = {
|
||||
js: "javascript",
|
||||
jsx: "javascript",
|
||||
ts: "typescript",
|
||||
tsx: "typescript",
|
||||
html: "html",
|
||||
css: "css",
|
||||
json: "json",
|
||||
md: "markdown",
|
||||
py: "python",
|
||||
java: "java",
|
||||
c: "c",
|
||||
cpp: "cpp",
|
||||
cs: "csharp",
|
||||
go: "go",
|
||||
rs: "rust",
|
||||
rb: "ruby",
|
||||
php: "php",
|
||||
swift: "swift",
|
||||
kt: "kotlin",
|
||||
// Add more as needed
|
||||
};
|
||||
|
||||
return languageMap[extension] || "plaintext";
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-4">Loading file content...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="p-4 text-red-500">Error: {error.message}</div>;
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return <div className="p-4 text-gray-500">No content available</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Breadcrumb path={filePath} hasUnsavedChanges={displayUnsavedChanges} />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage={getLanguage(filePath)}
|
||||
value={value}
|
||||
theme={editorTheme}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorDidMount}
|
||||
options={{
|
||||
minimap: { enabled: true },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
fontFamily: "monospace",
|
||||
fontSize: 13,
|
||||
lineNumbers: "on",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
127
src/components/preview_panel/FileTree.tsx
Normal file
127
src/components/preview_panel/FileTree.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React from "react";
|
||||
import { Folder, FolderOpen } from "lucide-react";
|
||||
import { selectedFileAtom } from "@/atoms/viewAtoms";
|
||||
import { useSetAtom } from "jotai";
|
||||
|
||||
interface FileTreeProps {
|
||||
files: string[];
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
children: TreeNode[];
|
||||
}
|
||||
|
||||
// Convert flat file list to tree structure
|
||||
const buildFileTree = (files: string[]): TreeNode[] => {
|
||||
const root: TreeNode[] = [];
|
||||
|
||||
files.forEach((path) => {
|
||||
const parts = path.split("/");
|
||||
let currentLevel = root;
|
||||
|
||||
parts.forEach((part, index) => {
|
||||
const isLastPart = index === parts.length - 1;
|
||||
const currentPath = parts.slice(0, index + 1).join("/");
|
||||
|
||||
// Check if this node already exists at the current level
|
||||
const existingNode = currentLevel.find((node) => node.name === part);
|
||||
|
||||
if (existingNode) {
|
||||
// If we found the node, just drill down to its children for the next level
|
||||
currentLevel = existingNode.children;
|
||||
} else {
|
||||
// Create a new node
|
||||
const newNode: TreeNode = {
|
||||
name: part,
|
||||
path: currentPath,
|
||||
isDirectory: !isLastPart,
|
||||
children: [],
|
||||
};
|
||||
|
||||
currentLevel.push(newNode);
|
||||
currentLevel = newNode.children;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return root;
|
||||
};
|
||||
|
||||
// File tree component
|
||||
export const FileTree = ({ files }: FileTreeProps) => {
|
||||
const treeData = buildFileTree(files);
|
||||
|
||||
return (
|
||||
<div className="file-tree mt-2">
|
||||
<TreeNodes nodes={treeData} level={0} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TreeNodesProps {
|
||||
nodes: TreeNode[];
|
||||
level: number;
|
||||
}
|
||||
|
||||
// Sort nodes to show directories first
|
||||
const sortNodes = (nodes: TreeNode[]): TreeNode[] => {
|
||||
return [...nodes].sort((a, b) => {
|
||||
if (a.isDirectory === b.isDirectory) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
return a.isDirectory ? -1 : 1;
|
||||
});
|
||||
};
|
||||
|
||||
// Tree nodes component
|
||||
const TreeNodes = ({ nodes, level }: TreeNodesProps) => (
|
||||
<ul className="ml-4">
|
||||
{sortNodes(nodes).map((node, index) => (
|
||||
<TreeNode key={index} node={node} level={level} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: TreeNode;
|
||||
level: number;
|
||||
}
|
||||
|
||||
// Individual tree node component
|
||||
const TreeNode = ({ node, level }: TreeNodeProps) => {
|
||||
const [expanded, setExpanded] = React.useState(level < 2);
|
||||
const setSelectedFile = useSetAtom(selectedFileAtom);
|
||||
|
||||
const handleClick = () => {
|
||||
if (node.isDirectory) {
|
||||
setExpanded(!expanded);
|
||||
} else {
|
||||
setSelectedFile({
|
||||
path: node.path,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="py-0.5">
|
||||
<div
|
||||
className="flex items-center hover:bg-(--sidebar) rounded cursor-pointer px-1.5 py-0.5 text-sm"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{node.isDirectory && (
|
||||
<span className="mr-1 text-gray-500">
|
||||
{expanded ? <FolderOpen size={16} /> : <Folder size={16} />}
|
||||
</span>
|
||||
)}
|
||||
<span>{node.name}</span>
|
||||
</div>
|
||||
|
||||
{node.isDirectory && expanded && node.children.length > 0 && (
|
||||
<TreeNodes nodes={node.children} level={level + 1} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
414
src/components/preview_panel/PreviewIframe.tsx
Normal file
414
src/components/preview_panel/PreviewIframe.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
import { selectedAppIdAtom, appUrlAtom, appOutputAtom } from "@/atoms/appAtoms";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useRunApp } from "@/hooks/useRunApp";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
Maximize2,
|
||||
Loader2,
|
||||
X,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
Lightbulb,
|
||||
} from "lucide-react";
|
||||
import { chatInputValueAtom } from "@/atoms/chatAtoms";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { useLoadApp } from "@/hooks/useLoadApp";
|
||||
import { useLoadAppFile } from "@/hooks/useLoadAppFile";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface ErrorBannerProps {
|
||||
error: string | null;
|
||||
onDismiss: () => void;
|
||||
onAIFix: () => void;
|
||||
}
|
||||
|
||||
const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => {
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute top-2 left-2 right-2 z-10 bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 rounded-md shadow-sm p-2">
|
||||
{/* Close button in top left */}
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="absolute top-1 left-1 p-1 hover:bg-red-100 dark:hover:bg-red-900/50 rounded"
|
||||
>
|
||||
<X size={14} className="text-red-500 dark:text-red-400" />
|
||||
</button>
|
||||
|
||||
{/* Error message in the middle */}
|
||||
<div className="px-6 py-1 text-sm">
|
||||
<div className="text-red-700 dark:text-red-300 text-wrap">{error}</div>
|
||||
</div>
|
||||
|
||||
{/* Tip message */}
|
||||
<div className="mt-2 px-6">
|
||||
<div className="relative p-2 bg-red-100 dark:bg-red-900/50 rounded-sm flex gap-1 items-center">
|
||||
<div>
|
||||
<Lightbulb size={16} className=" text-red-800 dark:text-red-300" />
|
||||
</div>
|
||||
<span className="text-sm text-red-700 dark:text-red-400">
|
||||
<span className="font-medium">Tip: </span>Check if refreshing the
|
||||
page or restarting the app fixes the error.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Fix button at the bottom */}
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
onClick={onAIFix}
|
||||
className="cursor-pointer flex items-center space-x-1 px-2 py-0.5 bg-red-500 dark:bg-red-600 text-white rounded text-sm hover:bg-red-600 dark:hover:bg-red-700"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span>Fix error with AI</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Preview iframe component
|
||||
export const PreviewIframe = ({
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}) => {
|
||||
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
||||
const { appUrl } = useAtomValue(appUrlAtom);
|
||||
const setAppOutput = useSetAtom(appOutputAtom);
|
||||
const { app } = useLoadApp(selectedAppId);
|
||||
|
||||
// State to trigger iframe reload
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [iframeError, setIframeError] = useState<string | null>(null);
|
||||
const [showError, setShowError] = useState(true);
|
||||
const setInputValue = useSetAtom(chatInputValueAtom);
|
||||
const [availableRoutes, setAvailableRoutes] = useState<
|
||||
Array<{ path: string; label: string }>
|
||||
>([]);
|
||||
|
||||
// Load router related files to extract routes
|
||||
const { content: routerContent } = useLoadAppFile(
|
||||
selectedAppId,
|
||||
"src/App.tsx"
|
||||
);
|
||||
|
||||
// Effect to parse routes from the router file
|
||||
useEffect(() => {
|
||||
if (routerContent) {
|
||||
console.log("routerContent", routerContent);
|
||||
try {
|
||||
const routes: Array<{ path: string; label: string }> = [];
|
||||
|
||||
// Extract route imports and paths using regex for React Router syntax
|
||||
// Match <Route path="/path">
|
||||
const routePathsRegex = /<Route\s+(?:[^>]*\s+)?path=["']([^"']+)["']/g;
|
||||
let match;
|
||||
|
||||
// Find all route paths in the router content
|
||||
while ((match = routePathsRegex.exec(routerContent)) !== null) {
|
||||
const path = match[1];
|
||||
// Create a readable label from the path
|
||||
const label =
|
||||
path === "/"
|
||||
? "Home"
|
||||
: path
|
||||
.split("/")
|
||||
.filter((segment) => segment && !segment.startsWith(":"))
|
||||
.pop()
|
||||
?.replace(/[-_]/g, " ")
|
||||
.replace(/^\w/, (c) => c.toUpperCase()) || path;
|
||||
|
||||
if (!routes.some((r) => r.path === path)) {
|
||||
routes.push({ path, label });
|
||||
}
|
||||
}
|
||||
|
||||
setAvailableRoutes(routes);
|
||||
} catch (e) {
|
||||
console.error("Error parsing router file:", e);
|
||||
}
|
||||
}
|
||||
}, [routerContent]);
|
||||
|
||||
// Navigation state
|
||||
const [canGoBack, setCanGoBack] = useState(false);
|
||||
const [canGoForward, setCanGoForward] = useState(false);
|
||||
const [navigationHistory, setNavigationHistory] = useState<string[]>([]);
|
||||
const [currentHistoryPosition, setCurrentHistoryPosition] = useState(0);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// Add message listener for iframe errors and navigation events
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
// Only handle messages from our iframe
|
||||
if (event.source !== iframeRef.current?.contentWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, payload } = event.data;
|
||||
|
||||
if (type === "window-error") {
|
||||
const errorMessage = `Error in ${payload.filename} (line ${payload.lineno}, col ${payload.colno}): ${payload.message}`;
|
||||
console.error("Iframe error:", errorMessage);
|
||||
setIframeError(errorMessage);
|
||||
setAppOutput((prev) => [
|
||||
...prev,
|
||||
{
|
||||
message: `Iframe error: ${errorMessage}`,
|
||||
type: "client-error",
|
||||
appId: selectedAppId!,
|
||||
},
|
||||
]);
|
||||
} else if (type === "unhandled-rejection") {
|
||||
const errorMessage = `Unhandled Promise Rejection: ${payload.reason}`;
|
||||
console.error("Iframe unhandled rejection:", errorMessage);
|
||||
setIframeError(errorMessage);
|
||||
setAppOutput((prev) => [
|
||||
...prev,
|
||||
{
|
||||
message: `Iframe unhandled rejection: ${errorMessage}`,
|
||||
type: "client-error",
|
||||
appId: selectedAppId!,
|
||||
},
|
||||
]);
|
||||
} else if (type === "pushState" || type === "replaceState") {
|
||||
console.debug(`Navigation event: ${type}`, payload);
|
||||
|
||||
// Update navigation history based on the type of state change
|
||||
if (type === "pushState") {
|
||||
// For pushState, we trim any forward history and add the new URL
|
||||
const newHistory = [
|
||||
...navigationHistory.slice(0, currentHistoryPosition + 1),
|
||||
payload.newUrl,
|
||||
];
|
||||
setNavigationHistory(newHistory);
|
||||
setCurrentHistoryPosition(newHistory.length - 1);
|
||||
} else if (type === "replaceState") {
|
||||
// For replaceState, we replace the current URL
|
||||
const newHistory = [...navigationHistory];
|
||||
newHistory[currentHistoryPosition] = payload.newUrl;
|
||||
setNavigationHistory(newHistory);
|
||||
}
|
||||
|
||||
// Update navigation buttons state
|
||||
setCanGoBack(currentHistoryPosition > 0);
|
||||
setCanGoForward(currentHistoryPosition < navigationHistory.length - 1);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, [navigationHistory, currentHistoryPosition, selectedAppId]);
|
||||
|
||||
// Initialize navigation history when iframe loads
|
||||
useEffect(() => {
|
||||
if (appUrl) {
|
||||
setNavigationHistory([appUrl]);
|
||||
setCurrentHistoryPosition(0);
|
||||
setCanGoBack(false);
|
||||
setCanGoForward(false);
|
||||
}
|
||||
}, [appUrl]);
|
||||
|
||||
// Function to navigate back
|
||||
const handleNavigateBack = () => {
|
||||
if (canGoBack && iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "navigate",
|
||||
payload: { direction: "backward" },
|
||||
},
|
||||
"*"
|
||||
);
|
||||
|
||||
// Update our local state
|
||||
setCurrentHistoryPosition((prev) => prev - 1);
|
||||
setCanGoBack(currentHistoryPosition - 1 > 0);
|
||||
setCanGoForward(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to navigate forward
|
||||
const handleNavigateForward = () => {
|
||||
if (canGoForward && iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "navigate",
|
||||
payload: { direction: "forward" },
|
||||
},
|
||||
"*"
|
||||
);
|
||||
|
||||
// Update our local state
|
||||
setCurrentHistoryPosition((prev) => prev + 1);
|
||||
setCanGoBack(true);
|
||||
setCanGoForward(
|
||||
currentHistoryPosition + 1 < navigationHistory.length - 1
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to handle reload
|
||||
const handleReload = () => {
|
||||
setReloadKey((prevKey) => prevKey + 1);
|
||||
// Optionally, add logic here if you need to explicitly stop/start the app again
|
||||
// For now, just changing the key should remount the iframe
|
||||
console.debug("Reloading iframe preview for app", selectedAppId);
|
||||
};
|
||||
|
||||
// Function to navigate to a specific route
|
||||
const navigateToRoute = (path: string) => {
|
||||
if (iframeRef.current?.contentWindow && appUrl) {
|
||||
// Create the full URL by combining the base URL with the path
|
||||
const baseUrl = new URL(appUrl).origin;
|
||||
const newUrl = `${baseUrl}${path}`;
|
||||
|
||||
// Navigate to the URL
|
||||
iframeRef.current.contentWindow.location.href = newUrl;
|
||||
|
||||
// Update navigation history
|
||||
const newHistory = [
|
||||
...navigationHistory.slice(0, currentHistoryPosition + 1),
|
||||
newUrl,
|
||||
];
|
||||
setNavigationHistory(newHistory);
|
||||
setCurrentHistoryPosition(newHistory.length - 1);
|
||||
setCanGoBack(true);
|
||||
setCanGoForward(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Display loading state
|
||||
if (loading) {
|
||||
return <div className="p-4 dark:text-gray-300">Loading app preview...</div>;
|
||||
}
|
||||
|
||||
// Display message if no app is selected
|
||||
if (selectedAppId === null) {
|
||||
return (
|
||||
<div className="p-4 text-gray-500 dark:text-gray-400">
|
||||
Select an app to see the preview.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Browser-style header */}
|
||||
<div className="flex items-center p-2 border-b space-x-2 ">
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={!canGoBack || loading || !selectedAppId}
|
||||
onClick={handleNavigateBack}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={!canGoForward || loading || !selectedAppId}
|
||||
onClick={handleNavigateForward}
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReload}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={loading || !selectedAppId}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Address Bar with Routes Dropdown - using shadcn/ui dropdown-menu */}
|
||||
<div className="relative flex-grow">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="flex items-center justify-between px-3 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm text-gray-700 dark:text-gray-200 cursor-pointer w-full">
|
||||
<span>
|
||||
{navigationHistory[currentHistoryPosition]
|
||||
? new URL(navigationHistory[currentHistoryPosition])
|
||||
.pathname
|
||||
: "/"}
|
||||
</span>
|
||||
<ChevronDown size={14} />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-full">
|
||||
{availableRoutes.length > 0 ? (
|
||||
availableRoutes.map((route) => (
|
||||
<DropdownMenuItem
|
||||
key={route.path}
|
||||
onClick={() => navigateToRoute(route.path)}
|
||||
className="flex justify-between"
|
||||
>
|
||||
<span>{route.label}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400 text-xs">
|
||||
{route.path}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
) : (
|
||||
<DropdownMenuItem disabled>Loading routes...</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (appUrl) {
|
||||
IpcClient.getInstance().openExternalUrl(appUrl);
|
||||
}
|
||||
}}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-grow ">
|
||||
<ErrorBanner
|
||||
error={showError ? error?.message || iframeError : null}
|
||||
onDismiss={() => setShowError(false)}
|
||||
onAIFix={() => {
|
||||
setInputValue(`Fix the error in ${error?.message || iframeError}`);
|
||||
}}
|
||||
/>
|
||||
|
||||
{!appUrl ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center space-y-4 bg-gray-50 dark:bg-gray-950">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-gray-400 dark:text-gray-500" />
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Starting up your app...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
key={reloadKey}
|
||||
title={`Preview for App ${selectedAppId}`}
|
||||
className="w-full h-full border-none bg-white dark:bg-gray-950"
|
||||
src={appUrl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
192
src/components/preview_panel/PreviewPanel.tsx
Normal file
192
src/components/preview_panel/PreviewPanel.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { previewModeAtom, selectedAppIdAtom } from "../../atoms/appAtoms";
|
||||
import { useLoadApp } from "@/hooks/useLoadApp";
|
||||
import { CodeView } from "./CodeView";
|
||||
import { PreviewIframe } from "./PreviewIframe";
|
||||
import {
|
||||
Eye,
|
||||
Code,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Logs,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels";
|
||||
import { Console } from "./Console";
|
||||
import { useRunApp } from "@/hooks/useRunApp";
|
||||
|
||||
type PreviewMode = "preview" | "code";
|
||||
|
||||
interface PreviewHeaderProps {
|
||||
previewMode: PreviewMode;
|
||||
setPreviewMode: (mode: PreviewMode) => void;
|
||||
onRestart: () => void;
|
||||
}
|
||||
|
||||
// Preview Header component with preview mode toggle
|
||||
const PreviewHeader = ({
|
||||
previewMode,
|
||||
setPreviewMode,
|
||||
onRestart,
|
||||
}: PreviewHeaderProps) => (
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
|
||||
<div className="relative flex space-x-2 bg-[var(--background-darkest)] rounded-md p-0.5">
|
||||
<button
|
||||
className="relative flex items-center space-x-1 px-3 py-1 rounded-md text-sm z-10"
|
||||
onClick={() => setPreviewMode("preview")}
|
||||
>
|
||||
{previewMode === "preview" && (
|
||||
<motion.div
|
||||
layoutId="activeIndicator"
|
||||
className="absolute inset-0 bg-(--background-lightest) shadow rounded-md -z-1"
|
||||
transition={{ type: "spring", stiffness: 500, damping: 35 }}
|
||||
/>
|
||||
)}
|
||||
<Eye size={16} />
|
||||
<span>Preview</span>
|
||||
</button>
|
||||
<button
|
||||
className="relative flex items-center space-x-1 px-3 py-1 rounded-md text-sm z-10"
|
||||
onClick={() => setPreviewMode("code")}
|
||||
>
|
||||
{previewMode === "code" && (
|
||||
<motion.div
|
||||
layoutId="activeIndicator"
|
||||
className="absolute inset-0 bg-(--background-lightest) shadow rounded-md -z-1"
|
||||
transition={{ type: "spring", stiffness: 500, damping: 35 }}
|
||||
/>
|
||||
)}
|
||||
<Code size={16} />
|
||||
<span>Code</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRestart}
|
||||
className="flex items-center space-x-1 px-3 py-1 rounded-md text-sm hover:bg-[var(--background-darkest)] transition-colors"
|
||||
title="Restart App"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
<span>Restart</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Console header component
|
||||
const ConsoleHeader = ({
|
||||
isOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}) => (
|
||||
<div
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-2 px-4 py-1.5 border-t border-border cursor-pointer hover:bg-[var(--background-darkest)] transition-colors"
|
||||
>
|
||||
<Logs size={16} />
|
||||
<span className="text-sm font-medium">System Messages</span>
|
||||
<div className="flex-1" />
|
||||
{isOpen ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Main PreviewPanel component
|
||||
export function PreviewPanel() {
|
||||
const [previewMode, setPreviewMode] = useAtom(previewModeAtom);
|
||||
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
||||
const [isConsoleOpen, setIsConsoleOpen] = useState(false);
|
||||
const { runApp, stopApp, restartApp, error, loading, app } = useRunApp();
|
||||
const runningAppIdRef = useRef<number | null>(null);
|
||||
|
||||
const handleRestart = useCallback(() => {
|
||||
if (selectedAppId !== null) {
|
||||
restartApp(selectedAppId);
|
||||
}
|
||||
}, [selectedAppId, restartApp]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousAppId = runningAppIdRef.current;
|
||||
|
||||
// Check if the selected app ID has changed
|
||||
if (selectedAppId !== previousAppId) {
|
||||
// Stop the previously running app, if any
|
||||
if (previousAppId !== null) {
|
||||
console.debug("Stopping previous app", previousAppId);
|
||||
stopApp(previousAppId);
|
||||
// We don't necessarily nullify the ref here immediately,
|
||||
// let the start of the next app update it or unmount handle it.
|
||||
}
|
||||
|
||||
// Start the new app if an ID is selected
|
||||
if (selectedAppId !== null) {
|
||||
console.debug("Starting new app", selectedAppId);
|
||||
runApp(selectedAppId); // Consider adding error handling for the promise if needed
|
||||
runningAppIdRef.current = selectedAppId; // Update ref to the new running app ID
|
||||
} else {
|
||||
// If selectedAppId is null, ensure no app is marked as running
|
||||
runningAppIdRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup function: This runs when the component unmounts OR before the effect runs again.
|
||||
// We only want to stop the app on actual unmount. The logic above handles stopping
|
||||
// when the appId changes. So, we capture the running appId at the time the effect renders.
|
||||
const appToStopOnUnmount = runningAppIdRef.current;
|
||||
return () => {
|
||||
if (appToStopOnUnmount !== null) {
|
||||
const currentRunningApp = runningAppIdRef.current;
|
||||
if (currentRunningApp !== null) {
|
||||
console.debug(
|
||||
"Component unmounting or selectedAppId changing, stopping app",
|
||||
currentRunningApp
|
||||
);
|
||||
stopApp(currentRunningApp);
|
||||
runningAppIdRef.current = null; // Clear ref on stop
|
||||
}
|
||||
}
|
||||
};
|
||||
// Dependencies: run effect when selectedAppId changes.
|
||||
// runApp/stopApp are stable due to useCallback.
|
||||
}, [selectedAppId, runApp, stopApp]);
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PreviewHeader
|
||||
previewMode={previewMode}
|
||||
setPreviewMode={setPreviewMode}
|
||||
onRestart={handleRestart}
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<PanelGroup direction="vertical">
|
||||
<Panel id="content" minSize={30}>
|
||||
<div className="h-full overflow-y-auto">
|
||||
{previewMode === "preview" ? (
|
||||
<PreviewIframe loading={loading} error={error} />
|
||||
) : (
|
||||
<CodeView loading={loading} error={error} app={app} />
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
{isConsoleOpen && (
|
||||
<>
|
||||
<PanelResizeHandle className="h-1 bg-border hover:bg-gray-400 transition-colors cursor-row-resize" />
|
||||
<Panel id="console" minSize={10} defaultSize={30}>
|
||||
<div className="flex flex-col h-full">
|
||||
<ConsoleHeader
|
||||
isOpen={true}
|
||||
onToggle={() => setIsConsoleOpen(false)}
|
||||
/>
|
||||
<Console />
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
</PanelGroup>
|
||||
</div>
|
||||
{!isConsoleOpen && (
|
||||
<ConsoleHeader isOpen={false} onToggle={() => setIsConsoleOpen(true)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
357
src/components/settings/ProviderSettingsPage.tsx
Normal file
357
src/components/settings/ProviderSettingsPage.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ExternalLink,
|
||||
KeyRound,
|
||||
Info,
|
||||
Circle,
|
||||
Settings as SettingsIcon,
|
||||
GiftIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { PROVIDER_TO_ENV_VAR, PROVIDERS } from "@/constants/models";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
|
||||
interface ProviderSettingsPageProps {
|
||||
provider: string;
|
||||
}
|
||||
|
||||
// Helper function to mask ENV API keys (still needed for env vars)
|
||||
const maskEnvApiKey = (key: string | undefined): string => {
|
||||
if (!key) return "Not Set";
|
||||
if (key.length < 8) return "****";
|
||||
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`;
|
||||
};
|
||||
|
||||
export function ProviderSettingsPage({ provider }: ProviderSettingsPageProps) {
|
||||
const {
|
||||
settings,
|
||||
envVars,
|
||||
loading: settingsLoading,
|
||||
error: settingsError,
|
||||
updateSettings,
|
||||
} = useSettings();
|
||||
|
||||
const [apiKeyInput, setApiKeyInput] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
// Find provider details
|
||||
const providerInfo = PROVIDERS[provider as keyof typeof PROVIDERS];
|
||||
const providerDisplayName =
|
||||
providerInfo?.displayName ||
|
||||
provider.charAt(0).toUpperCase() + provider.slice(1);
|
||||
const providerWebsiteUrl = providerInfo?.websiteUrl;
|
||||
const hasFreeTier = providerInfo?.hasFreeTier;
|
||||
|
||||
const envVarName = PROVIDER_TO_ENV_VAR[provider];
|
||||
const envApiKey = envVars[envVarName];
|
||||
const userApiKey = settings?.providerSettings?.[provider]?.apiKey;
|
||||
|
||||
// --- Configuration Logic --- Updated Priority ---
|
||||
const isValidUserKey =
|
||||
!!userApiKey &&
|
||||
!userApiKey.startsWith("Invalid Key") &&
|
||||
userApiKey !== "Not Set";
|
||||
const hasEnvKey = !!envApiKey;
|
||||
|
||||
const isConfigured = isValidUserKey || hasEnvKey; // Configured if either is set
|
||||
// Settings key takes precedence if it's valid
|
||||
const activeKeySource = isValidUserKey
|
||||
? "settings"
|
||||
: hasEnvKey
|
||||
? "env"
|
||||
: "none";
|
||||
|
||||
// --- Accordion Logic ---
|
||||
const defaultAccordionValue = [];
|
||||
if (isValidUserKey || !hasEnvKey) {
|
||||
// If user key is set OR env key is NOT set, open the settings accordion item
|
||||
defaultAccordionValue.push("settings-key");
|
||||
}
|
||||
if (hasEnvKey) {
|
||||
defaultAccordionValue.push("env-key");
|
||||
}
|
||||
|
||||
// --- Save Handler ---
|
||||
const handleSaveKey = async () => {
|
||||
if (!apiKeyInput) {
|
||||
setSaveError("API Key cannot be empty.");
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await updateSettings({
|
||||
providerSettings: {
|
||||
...settings?.providerSettings,
|
||||
[provider]: {
|
||||
...(settings?.providerSettings?.[provider] || {}),
|
||||
apiKey: apiKeyInput,
|
||||
},
|
||||
},
|
||||
});
|
||||
setApiKeyInput(""); // Clear input on success
|
||||
// Optionally show a success message
|
||||
} catch (error: any) {
|
||||
console.error("Error saving API key:", error);
|
||||
setSaveError(error.message || "Failed to save API key.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Delete Handler ---
|
||||
const handleDeleteKey = async () => {
|
||||
setIsSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await updateSettings({
|
||||
providerSettings: {
|
||||
...settings?.providerSettings,
|
||||
[provider]: {
|
||||
...(settings?.providerSettings?.[provider] || {}),
|
||||
apiKey: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Optionally show a success message
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting API key:", error);
|
||||
setSaveError(error.message || "Failed to delete API key.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Effect to clear input error when input changes
|
||||
useEffect(() => {
|
||||
if (saveError) {
|
||||
setSaveError(null);
|
||||
}
|
||||
}, [apiKeyInput]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Button
|
||||
onClick={() => router.history.back()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 mb-4 bg-(--background-lightest) py-5"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center mb-1">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mr-3">
|
||||
Configure {providerDisplayName}
|
||||
</h1>
|
||||
{settingsLoading ? (
|
||||
<Skeleton className="h-6 w-6 rounded-full" />
|
||||
) : (
|
||||
<Circle
|
||||
className={`h-5 w-5 ${
|
||||
isConfigured
|
||||
? "fill-green-500 text-green-600"
|
||||
: "fill-yellow-400 text-yellow-500"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<span className="ml-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{settingsLoading
|
||||
? "Loading..."
|
||||
: isConfigured
|
||||
? "Setup Complete"
|
||||
: "Not Setup"}
|
||||
</span>
|
||||
</div>
|
||||
{!settingsLoading && 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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{providerWebsiteUrl && !settingsLoading && (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
IpcClient.getInstance().openExternalUrl(providerWebsiteUrl);
|
||||
}}
|
||||
className="mb-4 bg-(--background-lightest) cursor-pointer py-5"
|
||||
variant="outline"
|
||||
>
|
||||
{isConfigured ? (
|
||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<KeyRound className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isConfigured ? "Manage API Keys" : "Setup API Key"}
|
||||
<ExternalLink className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{settingsLoading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
) : settingsError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error Loading Settings</AlertTitle>
|
||||
<AlertDescription>
|
||||
Could not load configuration data: {settingsError.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Accordion
|
||||
type="multiple"
|
||||
className="w-full space-y-4"
|
||||
defaultValue={defaultAccordionValue}
|
||||
>
|
||||
<AccordionItem
|
||||
value="settings-key"
|
||||
className="border rounded-lg px-4 bg-(--background-lightest)"
|
||||
>
|
||||
<AccordionTrigger className="text-lg font-medium hover:no-underline cursor-pointer">
|
||||
API Key from Settings
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pt-4 ">
|
||||
{isValidUserKey && (
|
||||
<Alert variant="default" className="mb-4">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
<AlertTitle className="flex justify-between items-center">
|
||||
<span>Current Key (Settings)</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDeleteKey}
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-1 h-7 px-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{isSaving ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className="font-mono text-sm">{userApiKey}</p>
|
||||
{activeKeySource === "settings" && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
|
||||
This key is currently active.
|
||||
</p>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="apiKeyInput"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{isValidUserKey ? "Update" : "Set"} {providerDisplayName}{" "}
|
||||
API Key
|
||||
</label>
|
||||
<div className="flex items-start space-x-2">
|
||||
<Input
|
||||
type="password"
|
||||
id="apiKeyInput"
|
||||
value={apiKeyInput}
|
||||
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||
placeholder={`Enter new ${providerDisplayName} API Key here`}
|
||||
className={`flex-grow ${
|
||||
saveError ? "border-red-500" : ""
|
||||
}`}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSaveKey}
|
||||
disabled={isSaving || !apiKeyInput}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save Key"}
|
||||
</Button>
|
||||
</div>
|
||||
{saveError && (
|
||||
<p className="text-xs text-red-600">{saveError}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Setting a key here will override the environment variable
|
||||
(if set).
|
||||
</p>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="env-key"
|
||||
className="border rounded-lg px-4 bg-(--background-lightest)"
|
||||
>
|
||||
<AccordionTrigger className="text-lg font-medium hover:no-underline cursor-pointer">
|
||||
API Key from Environment Variable
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pt-4">
|
||||
{hasEnvKey ? (
|
||||
<Alert variant="default">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
Environment Variable Key ({envVarName})
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className="font-mono text-sm">
|
||||
{maskEnvApiKey(envApiKey)}
|
||||
</p>
|
||||
{activeKeySource === "env" && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
|
||||
This key is currently active (no settings key set).
|
||||
</p>
|
||||
)}
|
||||
{activeKeySource === "settings" && (
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-1">
|
||||
This key is currently being overridden by the key set
|
||||
in Settings.
|
||||
</p>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert variant="default">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Environment Variable Not Set</AlertTitle>
|
||||
<AlertDescription>
|
||||
The{" "}
|
||||
<code className="font-mono bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">
|
||||
{envVarName}
|
||||
</code>{" "}
|
||||
environment variable is not set.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-3">
|
||||
This key is set outside the application. If present, it will
|
||||
be used only if no key is configured in the Settings section
|
||||
above. Requires app restart to detect changes.
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/components/ui/accordion.tsx
Normal file
64
src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import type * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
66
src/components/ui/alert.tsx
Normal file
66
src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import type * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
60
src/components/ui/button.tsx
Normal file
60
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import type * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
sidebar: "h-16 w-16",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
85
src/components/ui/card.tsx
Normal file
85
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
133
src/components/ui/dialog.tsx
Normal file
133
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
255
src/components/ui/dropdown-menu.tsx
Normal file
255
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
29
src/components/ui/popover.tsx
Normal file
29
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent };
|
||||
28
src/components/ui/separator.tsx
Normal file
28
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import type * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator-root"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
137
src/components/ui/sheet.tsx
Normal file
137
src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import type * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
708
src/components/ui/sidebar.tsx
Normal file
708
src/components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,708 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { Menu, PanelLeftIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "5rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed";
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? _open;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
_setOpen(openState);
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open]
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
setOpen((open) => !open);
|
||||
}, [setOpen]);
|
||||
|
||||
// Auto-collapse on small screens
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia("(max-width: 480px)");
|
||||
const handleResize = () => {
|
||||
if (mql.matches) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
mql.addEventListener("change", handleResize);
|
||||
handleResize(); // Check initial size
|
||||
|
||||
return () => mql.removeEventListener("change", handleResize);
|
||||
}, [setOpen]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed";
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, toggleSidebar]
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"bg-sidebar",
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right";
|
||||
variant?: "sidebar" | "floating" | "inset";
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
}) {
|
||||
const { state } = useSidebar();
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 flex h-svh w-(--sidebar-width) transition-[left,right,width,transform] duration-200 ease-linear",
|
||||
side === "left"
|
||||
? "left-0 translate-x-0 group-data-[collapsible=offcanvas]:translate-x-[-100%]"
|
||||
: "right-0 translate-x-0 group-data-[collapsible=offcanvas]:translate-x-[100%]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l border-sidebar-border",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="sidebar"
|
||||
className="cursor-pointer ml-1 hover:bg-sidebar"
|
||||
// className={cn("hidden", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
<span className="sr-only">Toggle Menu</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" align="center">
|
||||
Toggle Menu
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"in-data-[side=left][data-state=collapsed]_&]:cursor-e-resize in-data-[side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
// (Only the sidebarMenuButtonVariants constant is updated; the rest of the code remains unchanged)
|
||||
// Updated base classes:
|
||||
// • Changed flex direction to column and centered items.
|
||||
// • Enforced a fixed width (w-20) for consistent space.
|
||||
// • Removed text-left and gap changes to ensure the text label appears below the icon.
|
||||
"peer/menu-button flex flex-col items-center gap-1 w-16 overflow-hidden p-2 text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0 [&>span]:mt-1",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed"}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean;
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
};
|
||||
13
src/components/ui/skeleton.tsx
Normal file
13
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
61
src/components/ui/tooltip.tsx
Normal file
61
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import type * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
117
src/constants/models.ts
Normal file
117
src/constants/models.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { ModelProvider } from "@/lib/schemas";
|
||||
export interface ModelOption {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
export const MODEL_OPTIONS: Record<ModelProvider, ModelOption[]> = {
|
||||
openai: [
|
||||
{
|
||||
name: "gpt-4o",
|
||||
displayName: "GPT 4o",
|
||||
description: "Latest GPT-4 model optimized for performance",
|
||||
},
|
||||
{
|
||||
name: "o3-mini",
|
||||
displayName: "o3 mini",
|
||||
description: "Reasoning model",
|
||||
},
|
||||
],
|
||||
anthropic: [
|
||||
{
|
||||
name: "claude-3-7-sonnet-latest",
|
||||
displayName: "Claude 3.7 Sonnet",
|
||||
description: "Excellent coder",
|
||||
},
|
||||
],
|
||||
google: [
|
||||
{
|
||||
name: "gemini-2.5-pro-exp-03-25",
|
||||
displayName: "Gemini 2.5 Pro",
|
||||
description: "Experimental version of Google's Gemini 2.5 Pro model",
|
||||
tag: "Recommended",
|
||||
},
|
||||
],
|
||||
openrouter: [
|
||||
{
|
||||
name: "deepseek/deepseek-chat-v3-0324:free",
|
||||
displayName: "DeepSeek v3 (free)",
|
||||
description: "Use for free (data may be used for training)",
|
||||
},
|
||||
],
|
||||
auto: [
|
||||
{
|
||||
name: "auto",
|
||||
displayName: "Auto",
|
||||
description: "Automatically selects the best model",
|
||||
tag: "Default",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const PROVIDERS: Record<
|
||||
ModelProvider,
|
||||
{
|
||||
name: string;
|
||||
displayName: string;
|
||||
hasFreeTier?: boolean;
|
||||
websiteUrl?: string;
|
||||
}
|
||||
> = {
|
||||
openai: {
|
||||
name: "openai",
|
||||
displayName: "OpenAI",
|
||||
hasFreeTier: false,
|
||||
websiteUrl: "https://platform.openai.com/api-keys",
|
||||
},
|
||||
anthropic: {
|
||||
name: "anthropic",
|
||||
displayName: "Anthropic",
|
||||
hasFreeTier: false,
|
||||
websiteUrl: "https://console.anthropic.com/settings/keys",
|
||||
},
|
||||
google: {
|
||||
name: "google",
|
||||
displayName: "Google",
|
||||
hasFreeTier: true,
|
||||
websiteUrl: "https://aistudio.google.com/app/apikey",
|
||||
},
|
||||
openrouter: {
|
||||
name: "openrouter",
|
||||
displayName: "OpenRouter",
|
||||
hasFreeTier: true,
|
||||
websiteUrl: "https://openrouter.ai/settings/keys",
|
||||
},
|
||||
auto: {
|
||||
name: "auto",
|
||||
displayName: "Dyad",
|
||||
websiteUrl: "https://academy.dyad.sh/settings",
|
||||
},
|
||||
};
|
||||
|
||||
export const PROVIDER_TO_ENV_VAR: Record<string, string> = {
|
||||
openai: "OPENAI_API_KEY",
|
||||
anthropic: "ANTHROPIC_API_KEY",
|
||||
google: "GEMINI_API_KEY",
|
||||
};
|
||||
|
||||
export const ALLOWED_ENV_VARS = Object.keys(PROVIDER_TO_ENV_VAR).map(
|
||||
(provider) => PROVIDER_TO_ENV_VAR[provider]
|
||||
);
|
||||
|
||||
export const AUTO_MODELS = [
|
||||
{
|
||||
provider: "google",
|
||||
name: "gemini-2.5-pro-exp-03-25",
|
||||
},
|
||||
{
|
||||
provider: "anthropic",
|
||||
name: "claude-3-7-sonnet-latest",
|
||||
},
|
||||
{
|
||||
provider: "openai",
|
||||
name: "gpt-4o",
|
||||
},
|
||||
];
|
||||
57
src/contexts/ThemeContext.tsx
Normal file
57
src/contexts/ThemeContext.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "system" | "light" | "dark";
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
// Try to get the saved theme from localStorage
|
||||
const savedTheme = localStorage.getItem("theme") as Theme;
|
||||
return savedTheme || "system";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Save theme preference to localStorage
|
||||
localStorage.setItem("theme", theme);
|
||||
|
||||
// Handle system theme changes
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
const applyTheme = () => {
|
||||
const root = window.document.documentElement;
|
||||
const isDark =
|
||||
theme === "dark" || (theme === "system" && mediaQuery.matches);
|
||||
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(isDark ? "dark" : "light");
|
||||
};
|
||||
|
||||
applyTheme();
|
||||
|
||||
// Listen for system theme changes
|
||||
const listener = () => applyTheme();
|
||||
mediaQuery.addEventListener("change", listener);
|
||||
|
||||
return () => mediaQuery.removeEventListener("change", listener);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
90
src/db/index.ts
Normal file
90
src/db/index.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
type BetterSQLite3Database,
|
||||
drizzle,
|
||||
} from "drizzle-orm/better-sqlite3";
|
||||
import Database from "better-sqlite3";
|
||||
import * as schema from "./schema";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { getDyadAppPath, getUserDataPath } from "../paths/paths";
|
||||
|
||||
// Database connection factory
|
||||
let _db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
/**
|
||||
* Get the database path based on the current environment
|
||||
*/
|
||||
export function getDatabasePath(): string {
|
||||
return path.join(getUserDataPath(), "sqlite.db");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the database connection
|
||||
*/
|
||||
export function initializeDatabase(): BetterSQLite3Database<typeof schema> & {
|
||||
$client: Database.Database;
|
||||
} {
|
||||
if (_db) return _db as any;
|
||||
|
||||
const dbPath = getDatabasePath();
|
||||
console.log("Initializing database at:", dbPath);
|
||||
|
||||
// Check if the database file exists and remove it if it has issues
|
||||
try {
|
||||
// If the file exists but is empty or corrupted, it might cause issues
|
||||
if (fs.existsSync(dbPath)) {
|
||||
const stats = fs.statSync(dbPath);
|
||||
// If the file is very small, it might be corrupted
|
||||
if (stats.size < 100) {
|
||||
console.log(
|
||||
"Database file exists but may be corrupted. Removing it..."
|
||||
);
|
||||
fs.unlinkSync(dbPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking database file:", error);
|
||||
}
|
||||
|
||||
fs.mkdirSync(getUserDataPath(), { recursive: true });
|
||||
// Just a convenient time to create it.
|
||||
fs.mkdirSync(getDyadAppPath("."), { recursive: true });
|
||||
|
||||
// Open the database with a higher timeout
|
||||
const sqlite = new Database(dbPath, { timeout: 10000 });
|
||||
|
||||
// Enable foreign key constraints
|
||||
sqlite.pragma("foreign_keys = ON");
|
||||
|
||||
// Create DB instance with schema
|
||||
_db = drizzle(sqlite, { schema });
|
||||
|
||||
try {
|
||||
// Run migrations programmatically
|
||||
const migrationsFolder = path.join(process.cwd(), "drizzle");
|
||||
|
||||
// Verify migrations folder exists
|
||||
if (!fs.existsSync(migrationsFolder)) {
|
||||
console.error("Migrations folder not found:", migrationsFolder);
|
||||
} else {
|
||||
console.log("Running migrations from:", migrationsFolder);
|
||||
migrate(_db, { migrationsFolder });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Migration error:", error);
|
||||
}
|
||||
|
||||
return _db as any;
|
||||
}
|
||||
|
||||
// Initialize database on import
|
||||
try {
|
||||
initializeDatabase();
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize database:", error);
|
||||
}
|
||||
|
||||
export const db = _db as any as BetterSQLite3Database<typeof schema> & {
|
||||
$client: Database.Database;
|
||||
};
|
||||
58
src/db/schema.ts
Normal file
58
src/db/schema.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { relations } from "drizzle-orm";
|
||||
|
||||
export const apps = sqliteTable("apps", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
export const chats = sqliteTable("chats", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
appId: integer("app_id")
|
||||
.notNull()
|
||||
.references(() => apps.id, { onDelete: "cascade" }),
|
||||
title: text("title"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
export const messages = sqliteTable("messages", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
chatId: integer("chat_id")
|
||||
.notNull()
|
||||
.references(() => chats.id, { onDelete: "cascade" }),
|
||||
role: text("role", { enum: ["user", "assistant"] }).notNull(),
|
||||
content: text("content").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
// Define relations
|
||||
export const appsRelations = relations(apps, ({ many }) => ({
|
||||
chats: many(chats),
|
||||
}));
|
||||
|
||||
export const chatsRelations = relations(chats, ({ many, one }) => ({
|
||||
messages: many(messages),
|
||||
app: one(apps, {
|
||||
fields: [chats.appId],
|
||||
references: [apps.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const messagesRelations = relations(messages, ({ one }) => ({
|
||||
chat: one(chats, {
|
||||
fields: [messages.chatId],
|
||||
references: [chats.id],
|
||||
}),
|
||||
}));
|
||||
6
src/hooks/use-mobile.ts
Normal file
6
src/hooks/use-mobile.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
|
||||
|
||||
export function useIsMobile() {
|
||||
// Always return false to force desktop behavior
|
||||
return false;
|
||||
}
|
||||
42
src/hooks/useChats.ts
Normal file
42
src/hooks/useChats.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { chatsAtom, chatsLoadingAtom } from "@/atoms/chatAtoms";
|
||||
import { getAllChats } from "@/lib/chat";
|
||||
import type { ChatSummary } from "@/lib/schemas";
|
||||
|
||||
export function useChats(appId: number | null) {
|
||||
const [chats, setChats] = useAtom(chatsAtom);
|
||||
const [loading, setLoading] = useAtom(chatsLoadingAtom);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchChats = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const chatList = await getAllChats(appId || undefined);
|
||||
setChats(chatList);
|
||||
} catch (error) {
|
||||
console.error("Failed to load chats:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchChats();
|
||||
}, [appId, setChats, setLoading]);
|
||||
|
||||
const refreshChats = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const chatList = await getAllChats(appId || undefined);
|
||||
setChats(chatList);
|
||||
return chatList;
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh chats:", error);
|
||||
return [] as ChatSummary[];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { chats, loading, refreshChats };
|
||||
}
|
||||
61
src/hooks/useLoadApp.ts
Normal file
61
src/hooks/useLoadApp.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import type { App } from "@/ipc/ipc_types";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { currentAppAtom } from "@/atoms/appAtoms";
|
||||
|
||||
export function useLoadApp(appId: number | null) {
|
||||
const [app, setApp] = useAtom(currentAppAtom);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadApp = async () => {
|
||||
if (appId === null) {
|
||||
setApp(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
const appData = await ipcClient.getApp(appId);
|
||||
|
||||
setApp(appData);
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
console.error(`Error loading app ${appId}:`, error);
|
||||
setError(error instanceof Error ? error : new Error(String(error)));
|
||||
setApp(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadApp();
|
||||
}, [appId]);
|
||||
|
||||
const refreshApp = async () => {
|
||||
if (appId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
console.log("Refreshing app", appId);
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
const appData = await ipcClient.getApp(appId);
|
||||
console.log("App data", appData);
|
||||
setApp(appData);
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
console.error(`Error refreshing app ${appId}:`, error);
|
||||
setError(error instanceof Error ? error : new Error(String(error)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { app, loading, error, refreshApp };
|
||||
}
|
||||
62
src/hooks/useLoadAppFile.ts
Normal file
62
src/hooks/useLoadAppFile.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
|
||||
export function useLoadAppFile(appId: number | null, filePath: string | null) {
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadFile = async () => {
|
||||
if (appId === null || filePath === null) {
|
||||
setContent(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
const fileContent = await ipcClient.readAppFile(appId, filePath);
|
||||
|
||||
setContent(fileContent);
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error loading file ${filePath} for app ${appId}:`,
|
||||
error
|
||||
);
|
||||
setError(error instanceof Error ? error : new Error(String(error)));
|
||||
setContent(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadFile();
|
||||
}, [appId, filePath]);
|
||||
|
||||
const refreshFile = async () => {
|
||||
if (appId === null || filePath === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
const fileContent = await ipcClient.readAppFile(appId, filePath);
|
||||
setContent(fileContent);
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error refreshing file ${filePath} for app ${appId}:`,
|
||||
error
|
||||
);
|
||||
setError(error instanceof Error ? error : new Error(String(error)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { content, loading, error, refreshFile };
|
||||
}
|
||||
33
src/hooks/useLoadApps.ts
Normal file
33
src/hooks/useLoadApps.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { appBasePathAtom, appsListAtom } from "@/atoms/appAtoms";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
|
||||
export function useLoadApps() {
|
||||
const [apps, setApps] = useAtom(appsListAtom);
|
||||
const [appBasePath, setAppBasePath] = useAtom(appBasePathAtom);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const refreshApps = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
const appListResponse = await ipcClient.listApps();
|
||||
setApps(appListResponse.apps);
|
||||
setAppBasePath(appListResponse.appBasePath);
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
console.error("Error refreshing apps:", error);
|
||||
setError(error instanceof Error ? error : new Error(String(error)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [setApps, setError, setLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshApps();
|
||||
}, [refreshApps]);
|
||||
|
||||
return { apps, loading, error, refreshApps };
|
||||
}
|
||||
54
src/hooks/useLoadVersions.ts
Normal file
54
src/hooks/useLoadVersions.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { versionsListAtom } from "@/atoms/appAtoms";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
|
||||
export function useLoadVersions(appId: number | null) {
|
||||
const [versions, setVersions] = useAtom(versionsListAtom);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadVersions = async () => {
|
||||
// If no app is selected, clear versions and return
|
||||
if (appId === null) {
|
||||
setVersions([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
const versionsList = await ipcClient.listVersions({ appId });
|
||||
|
||||
setVersions(versionsList);
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
console.error("Error loading versions:", error);
|
||||
setError(error instanceof Error ? error : new Error(String(error)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadVersions();
|
||||
}, [appId, setVersions]);
|
||||
|
||||
const refreshVersions = async () => {
|
||||
if (appId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
const versionsList = await ipcClient.listVersions({ appId });
|
||||
setVersions(versionsList);
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
console.error("Error refreshing versions:", error);
|
||||
setError(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
};
|
||||
|
||||
return { versions, loading, error, refreshVersions };
|
||||
}
|
||||
99
src/hooks/useRunApp.ts
Normal file
99
src/hooks/useRunApp.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { appOutputAtom, appUrlAtom, currentAppAtom } from "@/atoms/appAtoms";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { App } from "@/ipc/ipc_types";
|
||||
|
||||
export function useRunApp() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [app, setApp] = useAtom(currentAppAtom);
|
||||
const setAppOutput = useSetAtom(appOutputAtom);
|
||||
const [appUrlObj, setAppUrlObj] = useAtom(appUrlAtom);
|
||||
|
||||
const runApp = useCallback(async (appId: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
console.debug("Running app", appId);
|
||||
|
||||
// Clear the URL and add restart message
|
||||
if (appUrlObj?.appId !== appId) {
|
||||
setAppUrlObj({ appUrl: null, appId: null });
|
||||
}
|
||||
setAppOutput((prev) => [
|
||||
...prev,
|
||||
{ message: "Trying to restart app...", type: "stdout", appId },
|
||||
]);
|
||||
const app = await ipcClient.getApp(appId);
|
||||
setApp(app);
|
||||
await ipcClient.runApp(appId, (output) => {
|
||||
setAppOutput((prev) => [...prev, output]);
|
||||
// Check if the output contains a localhost URL
|
||||
const urlMatch = output.message.match(/(https?:\/\/localhost:\d+\/?)/);
|
||||
if (urlMatch) {
|
||||
setAppUrlObj({ appUrl: urlMatch[1], appId });
|
||||
}
|
||||
});
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
console.error(`Error running app ${appId}:`, error);
|
||||
setError(error instanceof Error ? error : new Error(String(error)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopApp = useCallback(async (appId: number) => {
|
||||
if (appId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
await ipcClient.stopApp(appId);
|
||||
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
console.error(`Error stopping app ${appId}:`, error);
|
||||
setError(error instanceof Error ? error : new Error(String(error)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const restartApp = useCallback(async (appId: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
console.debug("Restarting app", appId);
|
||||
|
||||
// Clear the URL and add restart message
|
||||
setAppUrlObj({ appUrl: null, appId: null });
|
||||
setAppOutput((prev) => [
|
||||
...prev,
|
||||
{ message: "Restarting app...", type: "stdout", appId },
|
||||
]);
|
||||
|
||||
const app = await ipcClient.getApp(appId);
|
||||
setApp(app);
|
||||
await ipcClient.restartApp(appId, (output) => {
|
||||
setAppOutput((prev) => [...prev, output]);
|
||||
// Check if the output contains a localhost URL
|
||||
const urlMatch = output.message.match(/(https?:\/\/localhost:\d+\/?)/);
|
||||
if (urlMatch) {
|
||||
setAppUrlObj({ appUrl: urlMatch[1], appId });
|
||||
}
|
||||
});
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
console.error(`Error restarting app ${appId}:`, error);
|
||||
setError(error instanceof Error ? error : new Error(String(error)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { loading, error, runApp, stopApp, restartApp, app };
|
||||
}
|
||||
88
src/hooks/useSettings.ts
Normal file
88
src/hooks/useSettings.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { userSettingsAtom, envVarsAtom } from "@/atoms/appAtoms";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import type { UserSettings } from "@/lib/schemas";
|
||||
|
||||
const PROVIDER_TO_ENV_VAR: Record<string, string> = {
|
||||
openai: "OPENAI_API_KEY",
|
||||
anthropic: "ANTHROPIC_API_KEY",
|
||||
google: "GEMINI_API_KEY",
|
||||
};
|
||||
|
||||
// Define a type for the environment variables we expect
|
||||
type EnvVars = Record<string, string | undefined>;
|
||||
|
||||
export function useSettings() {
|
||||
const [settings, setSettingsAtom] = useAtom(userSettingsAtom);
|
||||
const [envVars, setEnvVarsAtom] = useAtom(envVarsAtom);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadInitialData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
// Fetch settings and env vars concurrently
|
||||
const [userSettings, fetchedEnvVars] = await Promise.all([
|
||||
ipcClient.getUserSettings(),
|
||||
ipcClient.getEnvVars(),
|
||||
]);
|
||||
setSettingsAtom(userSettings);
|
||||
setEnvVarsAtom(fetchedEnvVars);
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
console.error("Error loading initial data:", error);
|
||||
setError(error instanceof Error ? error : new Error(String(error)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadInitialData();
|
||||
// Only run once on mount, dependencies are stable getters/setters
|
||||
}, [setSettingsAtom, setEnvVarsAtom]);
|
||||
|
||||
const updateSettings = async (newSettings: Partial<UserSettings>) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
const updatedSettings = await ipcClient.setUserSettings(newSettings);
|
||||
setSettingsAtom(updatedSettings);
|
||||
setError(null);
|
||||
return updatedSettings;
|
||||
} catch (error) {
|
||||
console.error("Error updating settings:", error);
|
||||
setError(error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isProviderSetup = (provider: string) => {
|
||||
const providerSettings = settings?.providerSettings[provider];
|
||||
if (providerSettings) {
|
||||
return true;
|
||||
}
|
||||
if (envVars[PROVIDER_TO_ENV_VAR[provider]]) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return {
|
||||
settings,
|
||||
envVars,
|
||||
loading,
|
||||
error,
|
||||
updateSettings,
|
||||
isProviderSetup,
|
||||
isAnyProviderSetup: () => {
|
||||
return Object.keys(PROVIDER_TO_ENV_VAR).some((provider) =>
|
||||
isProviderSetup(provider)
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
128
src/hooks/useStreamChat.ts
Normal file
128
src/hooks/useStreamChat.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { Message } from "ai";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import {
|
||||
chatErrorAtom,
|
||||
chatMessagesAtom,
|
||||
chatStreamCountAtom,
|
||||
isStreamingAtom,
|
||||
} from "@/atoms/chatAtoms";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
|
||||
import type { ChatResponseEnd } from "@/ipc/ipc_types";
|
||||
import { useChats } from "./useChats";
|
||||
import { useLoadApp } from "./useLoadApp";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { useLoadVersions } from "./useLoadVersions";
|
||||
import { showError } from "@/lib/toast";
|
||||
|
||||
export function getRandomString() {
|
||||
return Math.random().toString(36).substring(2, 15);
|
||||
}
|
||||
|
||||
export function useStreamChat() {
|
||||
const [messages, setMessages] = useAtom(chatMessagesAtom);
|
||||
const [isStreaming, setIsStreaming] = useAtom(isStreamingAtom);
|
||||
const [error, setError] = useAtom(chatErrorAtom);
|
||||
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
|
||||
const [selectedAppId] = useAtom(selectedAppIdAtom);
|
||||
const { refreshChats } = useChats(selectedAppId);
|
||||
const { refreshApp } = useLoadApp(selectedAppId);
|
||||
const setStreamCount = useSetAtom(chatStreamCountAtom);
|
||||
const { refreshVersions } = useLoadVersions(selectedAppId);
|
||||
|
||||
const streamMessage = useCallback(
|
||||
async ({
|
||||
prompt,
|
||||
chatId,
|
||||
redo,
|
||||
}: {
|
||||
prompt: string;
|
||||
chatId: number;
|
||||
redo?: boolean;
|
||||
}) => {
|
||||
if (!prompt.trim() || !chatId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
console.log("streaming message - set messages", prompt);
|
||||
setMessages((currentMessages: Message[]) => {
|
||||
if (redo) {
|
||||
let remainingMessages = currentMessages.slice();
|
||||
if (
|
||||
currentMessages[currentMessages.length - 1].role === "assistant"
|
||||
) {
|
||||
remainingMessages = currentMessages.slice(0, -1);
|
||||
}
|
||||
return [
|
||||
...remainingMessages,
|
||||
{
|
||||
id: getRandomString(),
|
||||
role: "assistant",
|
||||
content: "",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
...currentMessages,
|
||||
{
|
||||
id: getRandomString(),
|
||||
role: "user",
|
||||
content: prompt,
|
||||
},
|
||||
{
|
||||
id: getRandomString(),
|
||||
role: "assistant",
|
||||
content: "",
|
||||
},
|
||||
];
|
||||
});
|
||||
setIsStreaming(true);
|
||||
setStreamCount((streamCount) => streamCount + 1);
|
||||
try {
|
||||
IpcClient.getInstance().streamMessage(prompt, {
|
||||
chatId,
|
||||
redo,
|
||||
onUpdate: (updatedMessages: Message[]) => {
|
||||
setMessages(updatedMessages);
|
||||
},
|
||||
onEnd: (response: ChatResponseEnd) => {
|
||||
if (response.updatedFiles) {
|
||||
setIsPreviewOpen(true);
|
||||
}
|
||||
|
||||
// Keep the same as below
|
||||
setIsStreaming(false);
|
||||
refreshChats();
|
||||
refreshApp();
|
||||
refreshVersions();
|
||||
},
|
||||
onError: (errorMessage: string) => {
|
||||
console.error(`[CHAT] Stream error for ${chatId}:`, errorMessage);
|
||||
setError(errorMessage);
|
||||
|
||||
// Keep the same as above
|
||||
setIsStreaming(false);
|
||||
refreshChats();
|
||||
refreshApp();
|
||||
refreshVersions();
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[CHAT] Exception during streaming setup:", error);
|
||||
setIsStreaming(false);
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
},
|
||||
[setMessages, setIsStreaming, setIsPreviewOpen]
|
||||
);
|
||||
|
||||
return {
|
||||
streamMessage,
|
||||
isStreaming,
|
||||
error,
|
||||
setError,
|
||||
setIsStreaming,
|
||||
};
|
||||
}
|
||||
915
src/ipc/handlers/app_handlers.ts
Normal file
915
src/ipc/handlers/app_handlers.ts
Normal file
@@ -0,0 +1,915 @@
|
||||
import { ipcMain } from "electron";
|
||||
import { db, getDatabasePath } from "../../db";
|
||||
import { apps, chats } from "../../db/schema";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import type { App, CreateAppParams, Version } from "../ipc_types";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getDyadAppPath, getUserDataPath } from "../../paths/paths";
|
||||
import { spawn } from "node:child_process";
|
||||
import git from "isomorphic-git";
|
||||
import { promises as fsPromises } from "node:fs";
|
||||
import { extractCodebase } from "../../utils/codebase";
|
||||
// Import our utility modules
|
||||
import { withLock } from "../utils/lock_utils";
|
||||
import {
|
||||
copyDirectoryRecursive,
|
||||
getFilesRecursively,
|
||||
} from "../utils/file_utils";
|
||||
import {
|
||||
runningApps,
|
||||
processCounter,
|
||||
killProcess,
|
||||
removeAppIfCurrentProcess,
|
||||
RunningAppInfo,
|
||||
} from "../utils/process_manager";
|
||||
import { ALLOWED_ENV_VARS } from "../../constants/models";
|
||||
|
||||
export function registerAppHandlers() {
|
||||
ipcMain.handle("create-app", async (_, params: CreateAppParams) => {
|
||||
const appPath = params.name;
|
||||
const fullAppPath = getDyadAppPath(appPath);
|
||||
if (fs.existsSync(fullAppPath)) {
|
||||
throw new Error(`App already exists at: ${fullAppPath}`);
|
||||
}
|
||||
// Create a new app
|
||||
const [app] = await db
|
||||
.insert(apps)
|
||||
.values({
|
||||
name: params.name,
|
||||
// Use the name as the path for now
|
||||
path: appPath,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Create an initial chat for this app
|
||||
const [chat] = await db
|
||||
.insert(chats)
|
||||
.values({
|
||||
appId: app.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Start async operations in background
|
||||
try {
|
||||
// Copy scaffold asynchronously
|
||||
await copyDirectoryRecursive(
|
||||
path.join(__dirname, "..", "..", "scaffold"),
|
||||
fullAppPath
|
||||
);
|
||||
// Initialize git repo and create first commit
|
||||
await git.init({
|
||||
fs: fs,
|
||||
dir: fullAppPath,
|
||||
defaultBranch: "main",
|
||||
});
|
||||
|
||||
// Stage all files
|
||||
await git.add({
|
||||
fs: fs,
|
||||
dir: fullAppPath,
|
||||
filepath: ".",
|
||||
});
|
||||
|
||||
// Create initial commit
|
||||
await git.commit({
|
||||
fs: fs,
|
||||
dir: fullAppPath,
|
||||
message: "Init from react vite template",
|
||||
author: {
|
||||
name: "Dyad",
|
||||
email: "dyad@example.com",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in background app initialization:", error);
|
||||
}
|
||||
// })();
|
||||
|
||||
return { app, chatId: chat.id };
|
||||
});
|
||||
|
||||
ipcMain.handle("get-app", async (_, appId: number): Promise<App> => {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
// Get app files
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
let files: string[] = [];
|
||||
|
||||
try {
|
||||
files = getFilesRecursively(appPath, appPath);
|
||||
} catch (error) {
|
||||
console.error(`Error reading files for app ${appId}:`, error);
|
||||
// Return app even if files couldn't be read
|
||||
}
|
||||
|
||||
return {
|
||||
...app,
|
||||
files,
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle("list-apps", async () => {
|
||||
const allApps = await db.query.apps.findMany({
|
||||
orderBy: [desc(apps.createdAt)],
|
||||
});
|
||||
return {
|
||||
apps: allApps,
|
||||
appBasePath: getDyadAppPath("$APP_BASE_PATH"),
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"read-app-file",
|
||||
async (_, { appId, filePath }: { appId: number; filePath: string }) => {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
const fullPath = path.join(appPath, filePath);
|
||||
|
||||
// Check if the path is within the app directory (security check)
|
||||
if (!fullPath.startsWith(appPath)) {
|
||||
throw new Error("Invalid file path");
|
||||
}
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
throw new Error("File not found");
|
||||
}
|
||||
|
||||
try {
|
||||
const contents = fs.readFileSync(fullPath, "utf-8");
|
||||
return contents;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error reading file ${filePath} for app ${appId}:`,
|
||||
error
|
||||
);
|
||||
throw new Error("Failed to read file");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle("get-env-vars", async () => {
|
||||
const envVars: Record<string, string | undefined> = {};
|
||||
for (const key of ALLOWED_ENV_VARS) {
|
||||
envVars[key] = process.env[key];
|
||||
}
|
||||
return envVars;
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"run-app",
|
||||
async (
|
||||
event: Electron.IpcMainInvokeEvent,
|
||||
{ appId }: { appId: number }
|
||||
) => {
|
||||
return withLock(appId, async () => {
|
||||
// Check if app is already running
|
||||
if (runningApps.has(appId)) {
|
||||
console.debug(`App ${appId} is already running.`);
|
||||
// Potentially return the existing process info or confirm status
|
||||
return { success: true, message: "App already running." };
|
||||
}
|
||||
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
console.debug(`Starting app ${appId} in path ${app.path}`);
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
console.log("appPath-CWD", appPath);
|
||||
try {
|
||||
const process = spawn("npm install && npm run dev", [], {
|
||||
cwd: appPath,
|
||||
shell: true,
|
||||
stdio: "pipe", // Ensure stdio is piped so we can capture output/errors and detect close
|
||||
detached: false, // Ensure child process is attached to the main process lifecycle unless explicitly backgrounded
|
||||
});
|
||||
|
||||
// Check if process spawned correctly
|
||||
if (!process.pid) {
|
||||
// Attempt to capture any immediate errors if possible
|
||||
let errorOutput = "";
|
||||
process.stderr?.on("data", (data) => (errorOutput += data));
|
||||
await new Promise((resolve) => process.on("error", resolve)); // Wait for error event
|
||||
throw new Error(
|
||||
`Failed to spawn process for app ${appId}. Error: ${
|
||||
errorOutput || "Unknown spawn error"
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
// Increment the counter and store the process reference with its ID
|
||||
const currentProcessId = processCounter.increment();
|
||||
runningApps.set(appId, { process, processId: currentProcessId });
|
||||
|
||||
// Log output
|
||||
process.stdout?.on("data", (data) => {
|
||||
console.log(
|
||||
`App ${appId} (PID: ${process.pid}) stdout: ${data
|
||||
.toString()
|
||||
.trim()}`
|
||||
);
|
||||
event.sender.send("app:output", {
|
||||
type: "stdout",
|
||||
message: data.toString().trim(),
|
||||
appId: appId,
|
||||
});
|
||||
});
|
||||
|
||||
process.stderr?.on("data", (data) => {
|
||||
console.error(
|
||||
`App ${appId} (PID: ${process.pid}) stderr: ${data
|
||||
.toString()
|
||||
.trim()}`
|
||||
);
|
||||
event.sender.send("app:output", {
|
||||
type: "stderr",
|
||||
message: data.toString().trim(),
|
||||
appId: appId,
|
||||
});
|
||||
});
|
||||
|
||||
// Handle process exit/close
|
||||
process.on("close", (code, signal) => {
|
||||
console.log(
|
||||
`App ${appId} (PID: ${process.pid}) process closed with code ${code}, signal ${signal}.`
|
||||
);
|
||||
removeAppIfCurrentProcess(appId, process);
|
||||
});
|
||||
|
||||
// Handle errors during process lifecycle (e.g., command not found)
|
||||
process.on("error", (err) => {
|
||||
console.error(
|
||||
`Error in app ${appId} (PID: ${process.pid}) process: ${err.message}`
|
||||
);
|
||||
removeAppIfCurrentProcess(appId, process);
|
||||
// Note: We don't throw here as the error is asynchronous. The caller got a success response already.
|
||||
// Consider adding ipcRenderer event emission to notify UI of the error.
|
||||
});
|
||||
|
||||
return { success: true, processId: currentProcessId };
|
||||
} catch (error: any) {
|
||||
console.error(`Error running app ${appId}:`, error);
|
||||
// Ensure cleanup if error happens during setup but before process events are handled
|
||||
if (
|
||||
runningApps.has(appId) &&
|
||||
runningApps.get(appId)?.processId === processCounter.value
|
||||
) {
|
||||
runningApps.delete(appId);
|
||||
}
|
||||
throw new Error(`Failed to run app ${appId}: ${error.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle("stop-app", async (_, { appId }: { appId: number }) => {
|
||||
console.log(
|
||||
`Attempting to stop app ${appId}. Current running apps: ${runningApps.size}`
|
||||
);
|
||||
// Use withLock to ensure atomicity of the stop operation
|
||||
return withLock(appId, async () => {
|
||||
const appInfo = runningApps.get(appId);
|
||||
|
||||
if (!appInfo) {
|
||||
console.log(
|
||||
`App ${appId} not found in running apps map. Assuming already stopped.`
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
message: "App not running or already stopped.",
|
||||
};
|
||||
}
|
||||
|
||||
const { process, processId } = appInfo;
|
||||
console.log(
|
||||
`Found running app ${appId} with processId ${processId} (PID: ${process.pid}). Attempting to stop.`
|
||||
);
|
||||
|
||||
// Check if the process is already exited or closed
|
||||
if (process.exitCode !== null || process.signalCode !== null) {
|
||||
console.log(
|
||||
`Process for app ${appId} (PID: ${process.pid}) already exited (code: ${process.exitCode}, signal: ${process.signalCode}). Cleaning up map.`
|
||||
);
|
||||
runningApps.delete(appId); // Ensure cleanup if somehow missed
|
||||
return { success: true, message: "Process already exited." };
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the killProcess utility to stop the process
|
||||
await killProcess(process);
|
||||
|
||||
// Now, safely remove the app from the map *after* confirming closure
|
||||
removeAppIfCurrentProcess(appId, process);
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`Error stopping app ${appId} (PID: ${process.pid}, processId: ${processId}):`,
|
||||
error
|
||||
);
|
||||
// Attempt cleanup even if an error occurred during the stop process
|
||||
removeAppIfCurrentProcess(appId, process);
|
||||
throw new Error(`Failed to stop app ${appId}: ${error.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"restart-app",
|
||||
async (
|
||||
event: Electron.IpcMainInvokeEvent,
|
||||
{ appId }: { appId: number }
|
||||
) => {
|
||||
return withLock(appId, async () => {
|
||||
try {
|
||||
// First stop the app if it's running
|
||||
const appInfo = runningApps.get(appId);
|
||||
if (appInfo) {
|
||||
const { process, processId } = appInfo;
|
||||
console.log(
|
||||
`Stopping app ${appId} (processId ${processId}) before restart`
|
||||
);
|
||||
|
||||
// Use the killProcess utility to stop the process
|
||||
await killProcess(process);
|
||||
|
||||
// Remove from running apps
|
||||
runningApps.delete(appId);
|
||||
}
|
||||
|
||||
// Now start the app again
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
console.debug(`Starting app ${appId} in path ${app.path}`);
|
||||
|
||||
const process = spawn("npm install && npm run dev", [], {
|
||||
cwd: appPath,
|
||||
shell: true,
|
||||
stdio: "pipe",
|
||||
detached: false,
|
||||
});
|
||||
|
||||
if (!process.pid) {
|
||||
let errorOutput = "";
|
||||
process.stderr?.on("data", (data) => (errorOutput += data));
|
||||
await new Promise((resolve) => process.on("error", resolve));
|
||||
throw new Error(
|
||||
`Failed to spawn process for app ${appId}. Error: ${
|
||||
errorOutput || "Unknown spawn error"
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
const currentProcessId = processCounter.increment();
|
||||
runningApps.set(appId, { process, processId: currentProcessId });
|
||||
|
||||
// Set up output handlers
|
||||
process.stdout?.on("data", (data) => {
|
||||
console.log(
|
||||
`App ${appId} (PID: ${process.pid}) stdout: ${data
|
||||
.toString()
|
||||
.trim()}`
|
||||
);
|
||||
event.sender.send("app:output", {
|
||||
type: "stdout",
|
||||
message: data.toString().trim(),
|
||||
appId: appId,
|
||||
});
|
||||
});
|
||||
|
||||
process.stderr?.on("data", (data) => {
|
||||
console.error(
|
||||
`App ${appId} (PID: ${process.pid}) stderr: ${data
|
||||
.toString()
|
||||
.trim()}`
|
||||
);
|
||||
event.sender.send("app:output", {
|
||||
type: "stderr",
|
||||
message: data.toString().trim(),
|
||||
appId: appId,
|
||||
});
|
||||
});
|
||||
|
||||
process.on("close", (code, signal) => {
|
||||
console.log(
|
||||
`App ${appId} (PID: ${process.pid}) process closed with code ${code}, signal ${signal}.`
|
||||
);
|
||||
removeAppIfCurrentProcess(appId, process);
|
||||
});
|
||||
|
||||
process.on("error", (err) => {
|
||||
console.error(
|
||||
`Error in app ${appId} (PID: ${process.pid}) process: ${err.message}`
|
||||
);
|
||||
removeAppIfCurrentProcess(appId, process);
|
||||
});
|
||||
|
||||
return { success: true, processId: currentProcessId };
|
||||
} catch (error) {
|
||||
console.error(`Error restarting app ${appId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle("list-versions", async (_, { appId }: { appId: number }) => {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
|
||||
// Just return an empty array if the app is not a git repo.
|
||||
if (!fs.existsSync(path.join(appPath, ".git"))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const commits = await git.log({
|
||||
fs,
|
||||
dir: appPath,
|
||||
depth: 1000, // Limit to last 1000 commits for performance
|
||||
});
|
||||
|
||||
return commits.map((commit) => ({
|
||||
oid: commit.oid,
|
||||
message: commit.commit.message,
|
||||
timestamp: commit.commit.author.timestamp,
|
||||
})) satisfies Version[];
|
||||
} catch (error: any) {
|
||||
console.error(`Error listing versions for app ${appId}:`, error);
|
||||
throw new Error(`Failed to list versions: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"revert-version",
|
||||
async (
|
||||
_,
|
||||
{ appId, previousVersionId }: { appId: number; previousVersionId: string }
|
||||
) => {
|
||||
return withLock(appId, async () => {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
|
||||
try {
|
||||
await git.checkout({
|
||||
fs,
|
||||
dir: appPath,
|
||||
ref: "main",
|
||||
force: true,
|
||||
});
|
||||
// Get status matrix comparing the target commit (previousVersionId as HEAD) with current working directory
|
||||
const matrix = await git.statusMatrix({
|
||||
fs,
|
||||
dir: appPath,
|
||||
ref: previousVersionId,
|
||||
});
|
||||
|
||||
// Process each file to revert to the state in previousVersionId
|
||||
for (const [
|
||||
filepath,
|
||||
headStatus,
|
||||
workdirStatus,
|
||||
stageStatus,
|
||||
] of matrix) {
|
||||
const fullPath = path.join(appPath, filepath);
|
||||
|
||||
// If file exists in HEAD (previous version)
|
||||
if (headStatus === 1) {
|
||||
// If file doesn't exist or has changed in working directory, restore it from the target commit
|
||||
if (workdirStatus !== 1) {
|
||||
const { blob } = await git.readBlob({
|
||||
fs,
|
||||
dir: appPath,
|
||||
oid: previousVersionId,
|
||||
filepath,
|
||||
});
|
||||
await fsPromises.mkdir(path.dirname(fullPath), {
|
||||
recursive: true,
|
||||
});
|
||||
await fsPromises.writeFile(fullPath, Buffer.from(blob));
|
||||
}
|
||||
}
|
||||
// If file doesn't exist in HEAD but exists in working directory, delete it
|
||||
else if (headStatus === 0 && workdirStatus !== 0) {
|
||||
if (fs.existsSync(fullPath)) {
|
||||
await fsPromises.unlink(fullPath);
|
||||
await git.remove({
|
||||
fs,
|
||||
dir: appPath,
|
||||
filepath: filepath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stage all changes
|
||||
await git.add({
|
||||
fs,
|
||||
dir: appPath,
|
||||
filepath: ".",
|
||||
});
|
||||
|
||||
// Create a revert commit
|
||||
await git.commit({
|
||||
fs,
|
||||
dir: appPath,
|
||||
message: `Reverted all changes back to version ${previousVersionId}`,
|
||||
author: {
|
||||
name: "Dyad",
|
||||
email: "hi@dyad.sh",
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`Error reverting to version ${previousVersionId} for app ${appId}:`,
|
||||
error
|
||||
);
|
||||
throw new Error(`Failed to revert version: ${error.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"checkout-version",
|
||||
async (_, { appId, versionId }: { appId: number; versionId: string }) => {
|
||||
return withLock(appId, async () => {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
|
||||
try {
|
||||
if (versionId !== "main") {
|
||||
// First check if the version exists
|
||||
const commits = await git.log({
|
||||
fs,
|
||||
dir: appPath,
|
||||
depth: 100,
|
||||
});
|
||||
|
||||
const targetCommit = commits.find((c) => c.oid === versionId);
|
||||
if (!targetCommit) {
|
||||
throw new Error("Target version not found");
|
||||
}
|
||||
}
|
||||
|
||||
// Checkout the target commit
|
||||
await git.checkout({
|
||||
fs,
|
||||
dir: appPath,
|
||||
ref: versionId,
|
||||
force: true,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`Error checking out version ${versionId} for app ${appId}:`,
|
||||
error
|
||||
);
|
||||
throw new Error(`Failed to checkout version: ${error.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Extract codebase information
|
||||
ipcMain.handle(
|
||||
"extract-codebase",
|
||||
async (_, { appId, maxFiles }: { appId: number; maxFiles?: number }) => {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
|
||||
try {
|
||||
return await extractCodebase(appPath, maxFiles);
|
||||
} catch (error) {
|
||||
console.error(`Error extracting codebase for app ${appId}:`, error);
|
||||
throw new Error(
|
||||
`Failed to extract codebase: ${(error as any).message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"edit-app-file",
|
||||
async (
|
||||
_,
|
||||
{
|
||||
appId,
|
||||
filePath,
|
||||
content,
|
||||
}: { appId: number; filePath: string; content: string }
|
||||
) => {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
const fullPath = path.join(appPath, filePath);
|
||||
|
||||
// Check if the path is within the app directory (security check)
|
||||
if (!fullPath.startsWith(appPath)) {
|
||||
throw new Error("Invalid file path");
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
const dirPath = path.dirname(fullPath);
|
||||
await fsPromises.mkdir(dirPath, { recursive: true });
|
||||
|
||||
try {
|
||||
await fsPromises.writeFile(fullPath, content, "utf-8");
|
||||
|
||||
// Check if git repository exists and commit the change
|
||||
if (fs.existsSync(path.join(appPath, ".git"))) {
|
||||
await git.add({
|
||||
fs,
|
||||
dir: appPath,
|
||||
filepath: filePath,
|
||||
});
|
||||
|
||||
await git.commit({
|
||||
fs,
|
||||
dir: appPath,
|
||||
message: `Updated ${filePath}`,
|
||||
author: {
|
||||
name: "Dyad",
|
||||
email: "hi@dyad.sh",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`Error writing file ${filePath} for app ${appId}:`,
|
||||
error
|
||||
);
|
||||
throw new Error(`Failed to write file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle("delete-app", async (_, { appId }: { appId: number }) => {
|
||||
return withLock(appId, async () => {
|
||||
// Check if app exists
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
// Stop the app if it's running
|
||||
if (runningApps.has(appId)) {
|
||||
const appInfo = runningApps.get(appId)!;
|
||||
try {
|
||||
await killProcess(appInfo.process);
|
||||
runningApps.delete(appId);
|
||||
} catch (error: any) {
|
||||
console.error(`Error stopping app ${appId} before deletion:`, error);
|
||||
// Continue with deletion even if stopping fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete app files
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
try {
|
||||
await fsPromises.rm(appPath, { recursive: true, force: true });
|
||||
} catch (error: any) {
|
||||
console.error(`Error deleting app files for app ${appId}:`, error);
|
||||
throw new Error(`Failed to delete app files: ${error.message}`);
|
||||
}
|
||||
|
||||
// Delete app from database
|
||||
try {
|
||||
await db.delete(apps).where(eq(apps.id, appId));
|
||||
// Note: Associated chats will cascade delete if that's set up in the schema
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error(`Error deleting app ${appId} from database:`, error);
|
||||
throw new Error(`Failed to delete app from database: ${error.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"rename-app",
|
||||
async (
|
||||
_,
|
||||
{
|
||||
appId,
|
||||
appName,
|
||||
appPath,
|
||||
}: { appId: number; appName: string; appPath: string }
|
||||
) => {
|
||||
return withLock(appId, async () => {
|
||||
// Check if app exists
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
// Check for conflicts with existing apps
|
||||
const nameConflict = await db.query.apps.findFirst({
|
||||
where: eq(apps.name, appName),
|
||||
});
|
||||
|
||||
const pathConflict = await db.query.apps.findFirst({
|
||||
where: eq(apps.path, appPath),
|
||||
});
|
||||
|
||||
if (nameConflict && nameConflict.id !== appId) {
|
||||
throw new Error(`An app with the name '${appName}' already exists`);
|
||||
}
|
||||
|
||||
if (pathConflict && pathConflict.id !== appId) {
|
||||
throw new Error(`An app with the path '${appPath}' already exists`);
|
||||
}
|
||||
|
||||
// Stop the app if it's running
|
||||
if (runningApps.has(appId)) {
|
||||
const appInfo = runningApps.get(appId)!;
|
||||
try {
|
||||
await killProcess(appInfo.process);
|
||||
runningApps.delete(appId);
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`Error stopping app ${appId} before renaming:`,
|
||||
error
|
||||
);
|
||||
throw new Error(
|
||||
`Failed to stop app before renaming: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const oldAppPath = getDyadAppPath(app.path);
|
||||
const newAppPath = getDyadAppPath(appPath);
|
||||
// Only move files if needed
|
||||
if (newAppPath !== oldAppPath) {
|
||||
// Move app files
|
||||
try {
|
||||
// Check if destination directory already exists
|
||||
if (fs.existsSync(newAppPath)) {
|
||||
throw new Error(
|
||||
`Destination path '${newAppPath}' already exists`
|
||||
);
|
||||
}
|
||||
|
||||
// Create parent directory if it doesn't exist
|
||||
await fsPromises.mkdir(path.dirname(newAppPath), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
// Move the files
|
||||
await fsPromises.rename(oldAppPath, newAppPath);
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`Error moving app files from ${oldAppPath} to ${newAppPath}:`,
|
||||
error
|
||||
);
|
||||
throw new Error(`Failed to move app files: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update app in database
|
||||
try {
|
||||
const [updatedApp] = await db
|
||||
.update(apps)
|
||||
.set({
|
||||
name: appName,
|
||||
path: appPath,
|
||||
})
|
||||
.where(eq(apps.id, appId))
|
||||
.returning();
|
||||
|
||||
return { success: true, app: updatedApp };
|
||||
} catch (error: any) {
|
||||
// Attempt to rollback the file move
|
||||
if (newAppPath !== oldAppPath) {
|
||||
try {
|
||||
await fsPromises.rename(newAppPath, oldAppPath);
|
||||
} catch (rollbackError) {
|
||||
console.error(
|
||||
`Failed to rollback file move during rename error:`,
|
||||
rollbackError
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`Error updating app ${appId} in database:`, error);
|
||||
throw new Error(`Failed to update app in database: ${error.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle("reset-all", async () => {
|
||||
// Stop all running apps first
|
||||
const runningAppIds = Array.from(runningApps.keys());
|
||||
for (const appId of runningAppIds) {
|
||||
try {
|
||||
const appInfo = runningApps.get(appId)!;
|
||||
await killProcess(appInfo.process);
|
||||
runningApps.delete(appId);
|
||||
} catch (error) {
|
||||
console.error(`Error stopping app ${appId} during reset:`, error);
|
||||
// Continue with reset even if stopping fails
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Remove all app files recursively
|
||||
const dyadAppPath = getDyadAppPath(".");
|
||||
if (fs.existsSync(dyadAppPath)) {
|
||||
await fsPromises.rm(dyadAppPath, { recursive: true, force: true });
|
||||
// Recreate the base directory
|
||||
await fsPromises.mkdir(dyadAppPath, { recursive: true });
|
||||
}
|
||||
|
||||
// 2. Drop the database by deleting the SQLite file
|
||||
const dbPath = getDatabasePath();
|
||||
if (fs.existsSync(dbPath)) {
|
||||
// Close database connections first
|
||||
if (db.$client) {
|
||||
db.$client.close();
|
||||
}
|
||||
await fsPromises.unlink(dbPath);
|
||||
console.log(`Database file deleted: ${dbPath}`);
|
||||
}
|
||||
|
||||
// 3. Remove settings
|
||||
const userDataPath = getUserDataPath();
|
||||
const settingsPath = path.join(userDataPath, "user-settings.json");
|
||||
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
await fsPromises.unlink(settingsPath);
|
||||
console.log(`Settings file deleted: ${settingsPath}`);
|
||||
}
|
||||
|
||||
return { success: true, message: "Successfully reset everything" };
|
||||
});
|
||||
}
|
||||
66
src/ipc/handlers/chat_handlers.ts
Normal file
66
src/ipc/handlers/chat_handlers.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { ipcMain } from "electron";
|
||||
import { db } from "../../db";
|
||||
import { chats } from "../../db/schema";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import type { ChatSummary } from "../../lib/schemas";
|
||||
|
||||
export function registerChatHandlers() {
|
||||
ipcMain.handle("create-chat", async (_, appId: number) => {
|
||||
// Create a new chat
|
||||
const [chat] = await db
|
||||
.insert(chats)
|
||||
.values({
|
||||
appId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return chat.id;
|
||||
});
|
||||
|
||||
ipcMain.handle("get-chat", async (_, chatId: number) => {
|
||||
const chat = await db.query.chats.findFirst({
|
||||
where: eq(chats.id, chatId),
|
||||
with: {
|
||||
messages: {
|
||||
orderBy: (messages, { asc }) => [asc(messages.createdAt)],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!chat) {
|
||||
throw new Error("Chat not found");
|
||||
}
|
||||
|
||||
return chat;
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"get-chats",
|
||||
async (_, appId?: number): Promise<ChatSummary[]> => {
|
||||
// If appId is provided, filter chats for that app
|
||||
const query = appId
|
||||
? db.query.chats.findMany({
|
||||
where: eq(chats.appId, appId),
|
||||
columns: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
appId: true,
|
||||
},
|
||||
orderBy: [desc(chats.createdAt)],
|
||||
})
|
||||
: db.query.chats.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
appId: true,
|
||||
},
|
||||
orderBy: [desc(chats.createdAt)],
|
||||
});
|
||||
|
||||
const allChats = await query;
|
||||
return allChats;
|
||||
}
|
||||
);
|
||||
}
|
||||
323
src/ipc/handlers/chat_stream_handlers.ts
Normal file
323
src/ipc/handlers/chat_stream_handlers.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { ipcMain } from "electron";
|
||||
import { streamText } from "ai";
|
||||
import { db } from "../../db";
|
||||
import { chats, messages } from "../../db/schema";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { SYSTEM_PROMPT } from "../../prompts/system_prompt";
|
||||
import { getDyadAppPath } from "../../paths/paths";
|
||||
import { readSettings } from "../../main/settings";
|
||||
import type { ChatResponseEnd, ChatStreamParams } from "../ipc_types";
|
||||
import { extractCodebase } from "../../utils/codebase";
|
||||
import { processFullResponseActions } from "../processors/response_processor";
|
||||
import { streamTestResponse } from "./testing_chat_handlers";
|
||||
import { getTestResponse } from "./testing_chat_handlers";
|
||||
import { getModelClient } from "../utils/get_model_client";
|
||||
|
||||
// Track active streams for cancellation
|
||||
const activeStreams = new Map<number, AbortController>();
|
||||
|
||||
// Track partial responses for cancelled streams
|
||||
const partialResponses = new Map<number, string>();
|
||||
|
||||
export function registerChatStreamHandlers() {
|
||||
ipcMain.handle("chat:stream", async (event, req: ChatStreamParams) => {
|
||||
try {
|
||||
// Create an AbortController for this stream
|
||||
const abortController = new AbortController();
|
||||
activeStreams.set(req.chatId, abortController);
|
||||
|
||||
// Get the chat to check for existing messages
|
||||
const chat = await db.query.chats.findFirst({
|
||||
where: eq(chats.id, req.chatId),
|
||||
with: {
|
||||
messages: {
|
||||
orderBy: (messages, { asc }) => [asc(messages.createdAt)],
|
||||
},
|
||||
app: true, // Include app information
|
||||
},
|
||||
});
|
||||
|
||||
if (!chat) {
|
||||
throw new Error(`Chat not found: ${req.chatId}`);
|
||||
}
|
||||
|
||||
// Handle redo option: remove the most recent messages if needed
|
||||
if (req.redo) {
|
||||
// Get the most recent messages
|
||||
const chatMessages = [...chat.messages];
|
||||
|
||||
// Find the most recent user message
|
||||
let lastUserMessageIndex = chatMessages.length - 1;
|
||||
while (
|
||||
lastUserMessageIndex >= 0 &&
|
||||
chatMessages[lastUserMessageIndex].role !== "user"
|
||||
) {
|
||||
lastUserMessageIndex--;
|
||||
}
|
||||
|
||||
if (lastUserMessageIndex >= 0) {
|
||||
// Delete the user message
|
||||
await db
|
||||
.delete(messages)
|
||||
.where(eq(messages.id, chatMessages[lastUserMessageIndex].id));
|
||||
|
||||
// If there's an assistant message after the user message, delete it too
|
||||
if (
|
||||
lastUserMessageIndex < chatMessages.length - 1 &&
|
||||
chatMessages[lastUserMessageIndex + 1].role === "assistant"
|
||||
) {
|
||||
await db
|
||||
.delete(messages)
|
||||
.where(
|
||||
eq(messages.id, chatMessages[lastUserMessageIndex + 1].id)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add user message to database
|
||||
await db
|
||||
.insert(messages)
|
||||
.values({
|
||||
chatId: req.chatId,
|
||||
role: "user",
|
||||
content: req.prompt,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Fetch updated chat data after possible deletions and additions
|
||||
const updatedChat = await db.query.chats.findFirst({
|
||||
where: eq(chats.id, req.chatId),
|
||||
with: {
|
||||
messages: {
|
||||
orderBy: (messages, { asc }) => [asc(messages.createdAt)],
|
||||
},
|
||||
app: true, // Include app information
|
||||
},
|
||||
});
|
||||
|
||||
if (!updatedChat) {
|
||||
throw new Error(`Chat not found: ${req.chatId}`);
|
||||
}
|
||||
|
||||
let fullResponse = "";
|
||||
|
||||
// Check if this is a test prompt
|
||||
const testResponse = getTestResponse(req.prompt);
|
||||
|
||||
if (testResponse) {
|
||||
// For test prompts, use the dedicated function
|
||||
fullResponse = await streamTestResponse(
|
||||
event,
|
||||
req.chatId,
|
||||
testResponse,
|
||||
abortController,
|
||||
updatedChat
|
||||
);
|
||||
} else {
|
||||
// Normal AI processing for non-test prompts
|
||||
const settings = readSettings();
|
||||
const modelClient = getModelClient(settings.selectedModel, settings);
|
||||
|
||||
// Extract codebase information if app is associated with the chat
|
||||
let codebaseInfo = "";
|
||||
if (updatedChat.app) {
|
||||
const appPath = getDyadAppPath(updatedChat.app.path);
|
||||
try {
|
||||
codebaseInfo = await extractCodebase(appPath);
|
||||
console.log(`Extracted codebase information from ${appPath}`);
|
||||
} catch (error) {
|
||||
console.error("Error extracting codebase:", error);
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
"codebaseInfo: length",
|
||||
codebaseInfo.length,
|
||||
"estimated tokens",
|
||||
codebaseInfo.length / 4
|
||||
);
|
||||
|
||||
// Append codebase information to the user's prompt if available
|
||||
const userPrompt = codebaseInfo
|
||||
? `${req.prompt}\n\nHere's the codebase:\n${codebaseInfo}`
|
||||
: req.prompt;
|
||||
|
||||
// Prepare message history for the AI
|
||||
const messageHistory = updatedChat.messages.map((message) => ({
|
||||
role: message.role as "user" | "assistant" | "system",
|
||||
content: message.content,
|
||||
}));
|
||||
|
||||
// Remove the last user message (we'll replace it with our enhanced version)
|
||||
if (
|
||||
messageHistory.length > 0 &&
|
||||
messageHistory[messageHistory.length - 1].role === "user"
|
||||
) {
|
||||
messageHistory.pop();
|
||||
}
|
||||
|
||||
const { textStream } = streamText({
|
||||
maxTokens: 8_000,
|
||||
model: modelClient,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: [
|
||||
...messageHistory,
|
||||
// Add the enhanced user prompt
|
||||
{
|
||||
role: "user",
|
||||
content: userPrompt,
|
||||
},
|
||||
],
|
||||
onError: (error) => {
|
||||
console.error("Error streaming text:", error);
|
||||
const message =
|
||||
(error as any)?.error?.message || JSON.stringify(error);
|
||||
event.sender.send(
|
||||
"chat:response:error",
|
||||
`Sorry, there was an error from the AI: ${message}`
|
||||
);
|
||||
// Clean up the abort controller
|
||||
activeStreams.delete(req.chatId);
|
||||
},
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
|
||||
// Process the stream as before
|
||||
try {
|
||||
for await (const textPart of textStream) {
|
||||
fullResponse += textPart;
|
||||
// Store the current partial response
|
||||
partialResponses.set(req.chatId, fullResponse);
|
||||
|
||||
// Update the assistant message in the database
|
||||
event.sender.send("chat:response:chunk", {
|
||||
chatId: req.chatId,
|
||||
messages: [
|
||||
...updatedChat.messages,
|
||||
{
|
||||
role: "assistant",
|
||||
content: fullResponse,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// If the stream was aborted, exit early
|
||||
if (abortController.signal.aborted) {
|
||||
console.log(`Stream for chat ${req.chatId} was aborted`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
// Check if this was an abort error
|
||||
if (abortController.signal.aborted) {
|
||||
const chatId = req.chatId;
|
||||
const partialResponse = partialResponses.get(req.chatId);
|
||||
// If we have a partial response, save it to the database
|
||||
if (partialResponse) {
|
||||
try {
|
||||
// Insert a new assistant message with the partial content
|
||||
await db.insert(messages).values({
|
||||
chatId,
|
||||
role: "assistant",
|
||||
content: `${partialResponse}\n\n[Response cancelled by user]`,
|
||||
});
|
||||
console.log(`Saved partial response for chat ${chatId}`);
|
||||
partialResponses.delete(chatId);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error saving partial response for chat ${chatId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
return req.chatId;
|
||||
}
|
||||
throw streamError;
|
||||
}
|
||||
}
|
||||
|
||||
// Only save the response and process it if we weren't aborted
|
||||
if (!abortController.signal.aborted && fullResponse) {
|
||||
// Scrape from: <dyad-chat-summary>Renaming profile file</dyad-chat-title>
|
||||
const chatTitle = fullResponse.match(
|
||||
/<dyad-chat-summary>(.*?)<\/dyad-chat-summary>/
|
||||
);
|
||||
if (chatTitle) {
|
||||
await db
|
||||
.update(chats)
|
||||
.set({ title: chatTitle[1] })
|
||||
.where(and(eq(chats.id, req.chatId), isNull(chats.title)));
|
||||
}
|
||||
const chatSummary = chatTitle?.[1];
|
||||
|
||||
// Create initial assistant message
|
||||
const [assistantMessage] = await db
|
||||
.insert(messages)
|
||||
.values({
|
||||
chatId: req.chatId,
|
||||
role: "assistant",
|
||||
content: fullResponse,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await db
|
||||
.update(messages)
|
||||
.set({ content: fullResponse })
|
||||
.where(eq(messages.id, assistantMessage.id));
|
||||
|
||||
const status = await processFullResponseActions(
|
||||
fullResponse,
|
||||
req.chatId,
|
||||
{ chatSummary }
|
||||
);
|
||||
|
||||
if (status.error) {
|
||||
event.sender.send(
|
||||
"chat:response:error",
|
||||
`Sorry, there was an error applying the AI's changes: ${status.error}`
|
||||
);
|
||||
}
|
||||
|
||||
// Signal that the stream has completed
|
||||
event.sender.send("chat:response:end", {
|
||||
chatId: req.chatId,
|
||||
updatedFiles: status.updatedFiles ?? false,
|
||||
} satisfies ChatResponseEnd);
|
||||
}
|
||||
|
||||
// Return the chat ID for backwards compatibility
|
||||
return req.chatId;
|
||||
} catch (error) {
|
||||
console.error("[MAIN] API error:", error);
|
||||
event.sender.send(
|
||||
"chat:response:error",
|
||||
`Sorry, there was an error processing your request: ${error}`
|
||||
);
|
||||
// Clean up the abort controller
|
||||
activeStreams.delete(req.chatId);
|
||||
return "error";
|
||||
}
|
||||
});
|
||||
|
||||
// Handler to cancel an ongoing stream
|
||||
ipcMain.handle("chat:cancel", async (event, chatId: number) => {
|
||||
const abortController = activeStreams.get(chatId);
|
||||
|
||||
if (abortController) {
|
||||
// Abort the stream
|
||||
abortController.abort();
|
||||
activeStreams.delete(chatId);
|
||||
console.log(`Aborted stream for chat ${chatId}`);
|
||||
} else {
|
||||
console.warn(`No active stream found for chat ${chatId}`);
|
||||
}
|
||||
|
||||
// Send the end event to the renderer
|
||||
event.sender.send("chat:response:end", {
|
||||
chatId,
|
||||
updatedFiles: false,
|
||||
} satisfies ChatResponseEnd);
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
126
src/ipc/handlers/dependency_handlers.ts
Normal file
126
src/ipc/handlers/dependency_handlers.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { ipcMain } from "electron";
|
||||
import { db } from "../../db";
|
||||
import { messages, apps, chats } from "../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { spawn } from "node:child_process";
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { getDyadAppPath } from "../../paths/paths";
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
export function registerDependencyHandlers() {
|
||||
ipcMain.handle(
|
||||
"chat:add-dep",
|
||||
async (
|
||||
_event,
|
||||
{ chatId, packages }: { chatId: number; packages: string[] }
|
||||
) => {
|
||||
// Find the message from the database
|
||||
const foundMessages = await db.query.messages.findMany({
|
||||
where: eq(messages.chatId, chatId),
|
||||
});
|
||||
|
||||
// Find the chat first
|
||||
const chat = await db.query.chats.findFirst({
|
||||
where: eq(chats.id, chatId),
|
||||
});
|
||||
|
||||
if (!chat) {
|
||||
throw new Error(`Chat ${chatId} not found`);
|
||||
}
|
||||
|
||||
// Get the app using the appId from the chat
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, chat.appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error(`App for chat ${chatId} not found`);
|
||||
}
|
||||
|
||||
const message = [...foundMessages]
|
||||
.reverse()
|
||||
.find((m) =>
|
||||
m.content.includes(
|
||||
`<dyad-add-dependency packages="${packages.join(" ")}">`
|
||||
)
|
||||
);
|
||||
|
||||
if (!message) {
|
||||
throw new Error(
|
||||
`Message with packages ${packages.join(", ")} not found`
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the message content contains the dependency tag
|
||||
const dependencyTagRegex = new RegExp(
|
||||
`<dyad-add-dependency packages="${packages.join(
|
||||
" "
|
||||
)}">[^<]*</dyad-add-dependency>`,
|
||||
"g"
|
||||
);
|
||||
|
||||
if (!dependencyTagRegex.test(message.content)) {
|
||||
throw new Error(
|
||||
`Message doesn't contain the dependency tag for packages ${packages.join(
|
||||
", "
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Execute npm install
|
||||
try {
|
||||
const { stdout, stderr } = await execPromise(
|
||||
`npm install ${packages.join(" ")}`,
|
||||
{
|
||||
cwd: getDyadAppPath(app.path),
|
||||
}
|
||||
);
|
||||
const installResults = stdout + (stderr ? `\n${stderr}` : "");
|
||||
|
||||
// Update the message content with the installation results
|
||||
const updatedContent = message.content.replace(
|
||||
new RegExp(
|
||||
`<dyad-add-dependency packages="${packages.join(
|
||||
" "
|
||||
)}">[^<]*</dyad-add-dependency>`,
|
||||
"g"
|
||||
),
|
||||
`<dyad-add-dependency packages="${packages.join(
|
||||
" "
|
||||
)}">${installResults}</dyad-add-dependency>`
|
||||
);
|
||||
|
||||
// Save the updated message back to the database
|
||||
await db
|
||||
.update(messages)
|
||||
.set({ content: updatedContent })
|
||||
.where(eq(messages.id, message.id));
|
||||
|
||||
// Return undefined implicitly
|
||||
} catch (error: any) {
|
||||
// Update the message with the error
|
||||
const updatedContent = message.content.replace(
|
||||
new RegExp(
|
||||
`<dyad-add-dependency packages="${packages.join(
|
||||
" "
|
||||
)}">[^<]*</dyad-add-dependency>`,
|
||||
"g"
|
||||
),
|
||||
`<dyad-add-dependency packages="${packages.join(" ")}"><dyad-error>${
|
||||
error.message
|
||||
}</dyad-error></dyad-add-dependency>`
|
||||
);
|
||||
|
||||
// Save the updated message back to the database
|
||||
await db
|
||||
.update(messages)
|
||||
.set({ content: updatedContent })
|
||||
.where(eq(messages.id, message.id));
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
44
src/ipc/handlers/settings_handlers.ts
Normal file
44
src/ipc/handlers/settings_handlers.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { ipcMain } from "electron";
|
||||
import type { UserSettings } from "../../lib/schemas";
|
||||
import { writeSettings } from "../../main/settings";
|
||||
import { readSettings } from "../../main/settings";
|
||||
|
||||
export function registerSettingsHandlers() {
|
||||
ipcMain.handle("get-user-settings", async () => {
|
||||
const settings = await readSettings();
|
||||
|
||||
// Mask API keys before sending to renderer
|
||||
if (settings?.providerSettings) {
|
||||
// Use optional chaining
|
||||
for (const providerKey in settings.providerSettings) {
|
||||
// Ensure the key is own property and providerSetting exists
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(
|
||||
settings.providerSettings,
|
||||
providerKey
|
||||
)
|
||||
) {
|
||||
const providerSetting = settings.providerSettings[providerKey];
|
||||
// Check if apiKey exists and is a non-empty string before masking
|
||||
if (
|
||||
providerSetting?.apiKey &&
|
||||
typeof providerSetting.apiKey === "string" &&
|
||||
providerSetting.apiKey.length > 0
|
||||
) {
|
||||
providerSetting.apiKey = providerSetting.apiKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return settings;
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"set-user-settings",
|
||||
async (_, settings: Partial<UserSettings>) => {
|
||||
writeSettings(settings);
|
||||
return readSettings();
|
||||
}
|
||||
);
|
||||
}
|
||||
21
src/ipc/handlers/shell_handler.ts
Normal file
21
src/ipc/handlers/shell_handler.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ipcMain, shell } from "electron";
|
||||
|
||||
export function registerShellHandlers() {
|
||||
ipcMain.handle("open-external-url", async (_event, url: string) => {
|
||||
try {
|
||||
// Basic validation to ensure it's a http/https url
|
||||
if (url && (url.startsWith("http://") || url.startsWith("https://"))) {
|
||||
await shell.openExternal(url);
|
||||
return { success: true };
|
||||
}
|
||||
console.error("Attempted to open invalid or non-http URL:", url);
|
||||
return {
|
||||
success: false,
|
||||
error: "Invalid URL provided. Only http/https URLs are allowed.",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to open external URL ${url}:`, error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
}
|
||||
16
src/ipc/handlers/shell_handlers.ts
Normal file
16
src/ipc/handlers/shell_handlers.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { IpcMainInvokeEvent } from "electron";
|
||||
import { shell } from "electron";
|
||||
|
||||
export async function handleShellOpenExternal(
|
||||
_event: IpcMainInvokeEvent,
|
||||
url: string
|
||||
): Promise<void> {
|
||||
// Basic validation to ensure it's likely a URL
|
||||
if (url && (url.startsWith("http://") || url.startsWith("https://"))) {
|
||||
await shell.openExternal(url);
|
||||
} else {
|
||||
console.error(`Invalid URL attempt blocked: ${url}`);
|
||||
// Optionally, you could throw an error back to the renderer
|
||||
// throw new Error("Invalid or insecure URL provided.");
|
||||
}
|
||||
}
|
||||
83
src/ipc/handlers/testing_chat_handlers.ts
Normal file
83
src/ipc/handlers/testing_chat_handlers.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// e.g. [dyad-qa=add-dep]
|
||||
// Canned responses for test prompts
|
||||
const TEST_RESPONSES: Record<string, string> = {
|
||||
"add-dep": `I'll add that dependency for you.
|
||||
|
||||
<dyad-add-dependency packages="deno"></dyad-add-dependency>
|
||||
|
||||
EOM`,
|
||||
"add-non-existing-dep": `I'll add that dependency for you.
|
||||
|
||||
<dyad-add-dependency packages="@angular/does-not-exist"></dyad-add-dependency>
|
||||
|
||||
EOM`,
|
||||
"add-multiple-deps": `I'll add that dependency for you.
|
||||
|
||||
<dyad-add-dependency packages="react-router-dom react-query"></dyad-add-dependency>
|
||||
|
||||
EOM`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a prompt is a test prompt and returns the corresponding canned response
|
||||
* @param prompt The user prompt
|
||||
* @returns The canned response if it's a test prompt, null otherwise
|
||||
*/
|
||||
export function getTestResponse(prompt: string): string | null {
|
||||
const match = prompt.match(/\[dyad-qa=([^\]]+)\]/);
|
||||
if (match) {
|
||||
const testKey = match[1];
|
||||
return TEST_RESPONSES[testKey] || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams a canned test response to the client
|
||||
* @param event The IPC event
|
||||
* @param chatId The chat ID
|
||||
* @param testResponse The canned response to stream
|
||||
* @param abortController The abort controller for this stream
|
||||
* @param updatedChat The chat data with messages
|
||||
* @returns The full streamed response
|
||||
*/
|
||||
export async function streamTestResponse(
|
||||
event: Electron.IpcMainInvokeEvent,
|
||||
chatId: number,
|
||||
testResponse: string,
|
||||
abortController: AbortController,
|
||||
updatedChat: any
|
||||
): Promise<string> {
|
||||
console.log(`Using canned response for test prompt`);
|
||||
|
||||
// Simulate streaming by splitting the response into chunks
|
||||
const chunks = testResponse.split(" ");
|
||||
let fullResponse = "";
|
||||
|
||||
for (const chunk of chunks) {
|
||||
// Skip processing if aborted
|
||||
if (abortController.signal.aborted) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Add the word plus a space
|
||||
fullResponse += chunk + " ";
|
||||
|
||||
// Send the current accumulated response
|
||||
event.sender.send("chat:response:chunk", {
|
||||
chatId: chatId,
|
||||
messages: [
|
||||
...updatedChat.messages,
|
||||
{
|
||||
role: "assistant",
|
||||
content: fullResponse,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Add a small delay to simulate streaming
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
return fullResponse;
|
||||
}
|
||||
477
src/ipc/ipc_client.ts
Normal file
477
src/ipc/ipc_client.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
import type { Message } from "ai";
|
||||
import type { IpcRenderer } from "electron";
|
||||
import {
|
||||
type ChatSummary,
|
||||
ChatSummariesSchema,
|
||||
type UserSettings,
|
||||
} from "../lib/schemas";
|
||||
import type {
|
||||
App,
|
||||
AppOutput,
|
||||
Chat,
|
||||
ChatResponseEnd,
|
||||
ChatStreamParams,
|
||||
CreateAppParams,
|
||||
CreateAppResult,
|
||||
ListAppsResponse,
|
||||
Version,
|
||||
} from "./ipc_types";
|
||||
import { showError } from "@/lib/toast";
|
||||
|
||||
export interface ChatStreamCallbacks {
|
||||
onUpdate: (messages: Message[]) => void;
|
||||
onEnd: (response: ChatResponseEnd) => void;
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
|
||||
export interface AppStreamCallbacks {
|
||||
onOutput: (output: AppOutput) => void;
|
||||
}
|
||||
|
||||
export class IpcClient {
|
||||
private static instance: IpcClient;
|
||||
private ipcRenderer: IpcRenderer;
|
||||
private chatStreams: Map<number, ChatStreamCallbacks>;
|
||||
private appStreams: Map<number, AppStreamCallbacks>;
|
||||
private constructor() {
|
||||
this.ipcRenderer = (window as any).electron.ipcRenderer as IpcRenderer;
|
||||
this.chatStreams = new Map();
|
||||
this.appStreams = new Map();
|
||||
// Set up listeners for stream events
|
||||
this.ipcRenderer.on("chat:response:chunk", (data) => {
|
||||
if (
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
"chatId" in data &&
|
||||
"messages" in data
|
||||
) {
|
||||
const { chatId, messages } = data as {
|
||||
chatId: number;
|
||||
messages: Message[];
|
||||
};
|
||||
|
||||
const callbacks = this.chatStreams.get(chatId);
|
||||
if (callbacks) {
|
||||
callbacks.onUpdate(messages);
|
||||
} else {
|
||||
console.warn(
|
||||
`[IPC] No callbacks found for chat ${chatId}`,
|
||||
this.chatStreams
|
||||
);
|
||||
}
|
||||
} else {
|
||||
showError(new Error(`[IPC] Invalid chunk data received: ${data}`));
|
||||
}
|
||||
});
|
||||
|
||||
this.ipcRenderer.on("app:output", (data) => {
|
||||
if (
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
"type" in data &&
|
||||
"message" in data &&
|
||||
"appId" in data
|
||||
) {
|
||||
const { type, message, appId } = data as AppOutput;
|
||||
const callbacks = this.appStreams.get(appId);
|
||||
if (callbacks) {
|
||||
callbacks.onOutput({ type, message, appId });
|
||||
}
|
||||
} else {
|
||||
showError(new Error(`[IPC] Invalid app output data received: ${data}`));
|
||||
}
|
||||
});
|
||||
|
||||
this.ipcRenderer.on("chat:response:end", (payload) => {
|
||||
const { chatId, updatedFiles } = payload as unknown as ChatResponseEnd;
|
||||
const callbacks = this.chatStreams.get(chatId);
|
||||
if (callbacks) {
|
||||
callbacks.onEnd({ chatId, updatedFiles });
|
||||
console.debug("chat:response:end");
|
||||
this.chatStreams.delete(chatId);
|
||||
} else {
|
||||
showError(
|
||||
new Error(`[IPC] No callbacks found for chat ${chatId} on stream end`)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.ipcRenderer.on("chat:response:error", (error) => {
|
||||
console.debug("chat:response:error");
|
||||
if (typeof error === "string") {
|
||||
for (const [chatId, callbacks] of this.chatStreams.entries()) {
|
||||
callbacks.onError(error);
|
||||
this.chatStreams.delete(chatId);
|
||||
}
|
||||
} else {
|
||||
console.error("[IPC] Invalid error data received:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static getInstance(): IpcClient {
|
||||
if (!IpcClient.instance) {
|
||||
IpcClient.instance = new IpcClient();
|
||||
}
|
||||
return IpcClient.instance;
|
||||
}
|
||||
|
||||
// Create a new app with an initial chat
|
||||
public async createApp(params: CreateAppParams): Promise<CreateAppResult> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("create-app", params);
|
||||
return result as CreateAppResult;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async getApp(appId: number): Promise<App> {
|
||||
try {
|
||||
const data = await this.ipcRenderer.invoke("get-app", appId);
|
||||
return data;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async getChat(chatId: number): Promise<Chat> {
|
||||
try {
|
||||
const data = await this.ipcRenderer.invoke("get-chat", chatId);
|
||||
return data;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all chats
|
||||
public async getChats(appId?: number): Promise<ChatSummary[]> {
|
||||
try {
|
||||
const data = await this.ipcRenderer.invoke("get-chats", appId);
|
||||
return ChatSummariesSchema.parse(data);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all apps
|
||||
public async listApps(): Promise<ListAppsResponse> {
|
||||
try {
|
||||
const data = await this.ipcRenderer.invoke("list-apps");
|
||||
return data;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Read a file from an app directory
|
||||
public async readAppFile(appId: number, filePath: string): Promise<string> {
|
||||
try {
|
||||
const content = await this.ipcRenderer.invoke("read-app-file", {
|
||||
appId,
|
||||
filePath,
|
||||
});
|
||||
return content as string;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Edit a file in an app directory
|
||||
public async editAppFile(
|
||||
appId: number,
|
||||
filePath: string,
|
||||
content: string
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("edit-app-file", {
|
||||
appId,
|
||||
filePath,
|
||||
content,
|
||||
});
|
||||
return result as { success: boolean };
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// New method for streaming responses
|
||||
public streamMessage(
|
||||
prompt: string,
|
||||
options: {
|
||||
chatId: number;
|
||||
redo?: boolean;
|
||||
onUpdate: (messages: Message[]) => void;
|
||||
onEnd: (response: ChatResponseEnd) => void;
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
): void {
|
||||
const { chatId, onUpdate, onEnd, onError, redo } = options;
|
||||
this.chatStreams.set(chatId, { onUpdate, onEnd, onError });
|
||||
|
||||
// Use invoke to start the stream and pass the chatId
|
||||
this.ipcRenderer
|
||||
.invoke("chat:stream", {
|
||||
prompt,
|
||||
chatId,
|
||||
redo,
|
||||
} satisfies ChatStreamParams)
|
||||
.catch((err) => {
|
||||
showError(err);
|
||||
onError(String(err));
|
||||
this.chatStreams.delete(chatId);
|
||||
});
|
||||
}
|
||||
|
||||
// Method to cancel an ongoing stream
|
||||
public cancelChatStream(chatId: number): void {
|
||||
this.ipcRenderer.invoke("chat:cancel", chatId);
|
||||
const callbacks = this.chatStreams.get(chatId);
|
||||
if (callbacks) {
|
||||
this.chatStreams.delete(chatId);
|
||||
} else {
|
||||
showError(new Error("Tried canceling chat that doesn't exist"));
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new chat for an app
|
||||
public async createChat(appId: number): Promise<number> {
|
||||
try {
|
||||
const chatId = await this.ipcRenderer.invoke("create-chat", appId);
|
||||
return chatId;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Open an external URL using the default browser
|
||||
public async openExternalUrl(
|
||||
url: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("open-external-url", url);
|
||||
return result as { success: boolean; error?: string };
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
// Ensure a consistent return type even on invoke error
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
// Run an app
|
||||
public async runApp(
|
||||
appId: number,
|
||||
onOutput: (output: AppOutput) => void
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("run-app", { appId });
|
||||
this.appStreams.set(appId, { onOutput });
|
||||
return result;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Stop a running app
|
||||
public async stopApp(appId: number): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("stop-app", { appId });
|
||||
return result;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Restart a running app
|
||||
public async restartApp(
|
||||
appId: number,
|
||||
onOutput: (output: AppOutput) => void
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("restart-app", { appId });
|
||||
this.appStreams.set(appId, { onOutput });
|
||||
return result;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get allow-listed environment variables
|
||||
public async getEnvVars(): Promise<Record<string, string | undefined>> {
|
||||
try {
|
||||
const envVars = await this.ipcRenderer.invoke("get-env-vars");
|
||||
return envVars as Record<string, string | undefined>;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// List all versions (commits) of an app
|
||||
public async listVersions({ appId }: { appId: number }): Promise<Version[]> {
|
||||
try {
|
||||
const versions = await this.ipcRenderer.invoke("list-versions", {
|
||||
appId,
|
||||
});
|
||||
return versions;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Revert to a specific version
|
||||
public async revertVersion({
|
||||
appId,
|
||||
previousVersionId,
|
||||
}: {
|
||||
appId: number;
|
||||
previousVersionId: string;
|
||||
}): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("revert-version", {
|
||||
appId,
|
||||
previousVersionId,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Checkout a specific version without creating a revert commit
|
||||
public async checkoutVersion({
|
||||
appId,
|
||||
versionId,
|
||||
}: {
|
||||
appId: number;
|
||||
versionId: string;
|
||||
}): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("checkout-version", {
|
||||
appId,
|
||||
versionId,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get user settings
|
||||
public async getUserSettings(): Promise<UserSettings> {
|
||||
try {
|
||||
const settings = await this.ipcRenderer.invoke("get-user-settings");
|
||||
return settings;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Update user settings
|
||||
public async setUserSettings(
|
||||
settings: Partial<UserSettings>
|
||||
): Promise<UserSettings> {
|
||||
try {
|
||||
const updatedSettings = await this.ipcRenderer.invoke(
|
||||
"set-user-settings",
|
||||
settings
|
||||
);
|
||||
return updatedSettings;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract codebase information for a given app
|
||||
public async extractCodebase(appId: number, maxFiles = 30): Promise<string> {
|
||||
try {
|
||||
const codebaseInfo = await this.ipcRenderer.invoke("extract-codebase", {
|
||||
appId,
|
||||
maxFiles,
|
||||
});
|
||||
return codebaseInfo as string;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete an app and all its files
|
||||
public async deleteApp(appId: number): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("delete-app", { appId });
|
||||
return result as { success: boolean };
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Rename an app (update name and path)
|
||||
public async renameApp({
|
||||
appId,
|
||||
appName,
|
||||
appPath,
|
||||
}: {
|
||||
appId: number;
|
||||
appName: string;
|
||||
appPath: string;
|
||||
}): Promise<{ success: boolean; app: App }> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("rename-app", {
|
||||
appId,
|
||||
appName,
|
||||
appPath,
|
||||
});
|
||||
return result as { success: boolean; app: App };
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset all - removes all app files, settings, and drops the database
|
||||
public async resetAll(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("reset-all");
|
||||
return result as { success: boolean; message: string };
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async addDependency({
|
||||
chatId,
|
||||
packages,
|
||||
}: {
|
||||
chatId: number;
|
||||
packages: string[];
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await this.ipcRenderer.invoke("chat:add-dep", {
|
||||
chatId,
|
||||
packages,
|
||||
});
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/ipc/ipc_host.ts
Normal file
16
src/ipc/ipc_host.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { registerAppHandlers } from "./handlers/app_handlers";
|
||||
import { registerChatHandlers } from "./handlers/chat_handlers";
|
||||
import { registerChatStreamHandlers } from "./handlers/chat_stream_handlers";
|
||||
import { registerSettingsHandlers } from "./handlers/settings_handlers";
|
||||
import { registerShellHandlers } from "./handlers/shell_handler";
|
||||
import { registerDependencyHandlers } from "./handlers/dependency_handlers";
|
||||
|
||||
export function registerIpcHandlers() {
|
||||
// Register all IPC handlers by category
|
||||
registerAppHandlers();
|
||||
registerChatHandlers();
|
||||
registerChatStreamHandlers();
|
||||
registerSettingsHandlers();
|
||||
registerShellHandlers();
|
||||
registerDependencyHandlers();
|
||||
}
|
||||
60
src/ipc/ipc_types.ts
Normal file
60
src/ipc/ipc_types.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Message } from "ai";
|
||||
|
||||
export interface AppOutput {
|
||||
type: "stdout" | "stderr" | "info" | "client-error";
|
||||
message: string;
|
||||
appId: number;
|
||||
}
|
||||
|
||||
export interface ListAppsResponse {
|
||||
apps: App[];
|
||||
appBasePath: string;
|
||||
}
|
||||
|
||||
export interface ChatStreamParams {
|
||||
chatId: number;
|
||||
prompt: string;
|
||||
redo?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatResponseEnd {
|
||||
chatId: number;
|
||||
updatedFiles: boolean;
|
||||
}
|
||||
|
||||
export interface CreateAppParams {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface CreateAppResult {
|
||||
app: {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
chatId: number;
|
||||
}
|
||||
|
||||
export interface Chat {
|
||||
id: number;
|
||||
title: string;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export interface App {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
files: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Version {
|
||||
oid: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}
|
||||
220
src/ipc/processors/response_processor.ts
Normal file
220
src/ipc/processors/response_processor.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { db } from "../../db";
|
||||
import { chats } from "../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import fs from "node:fs";
|
||||
import { getDyadAppPath } from "../../paths/paths";
|
||||
import path from "node:path";
|
||||
import git from "isomorphic-git";
|
||||
|
||||
export function getDyadWriteTags(fullResponse: string): {
|
||||
path: string;
|
||||
content: string;
|
||||
}[] {
|
||||
const dyadWriteRegex =
|
||||
/<dyad-write path="([^"]+)"[^>]*>([\s\S]*?)<\/dyad-write>/g;
|
||||
let match;
|
||||
const tags: { path: string; content: string }[] = [];
|
||||
while ((match = dyadWriteRegex.exec(fullResponse)) !== null) {
|
||||
tags.push({ path: match[1], content: match[2] });
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
export function getDyadRenameTags(fullResponse: string): {
|
||||
from: string;
|
||||
to: string;
|
||||
}[] {
|
||||
const dyadRenameRegex =
|
||||
/<dyad-rename from="([^"]+)" to="([^"]+)"[^>]*>([\s\S]*?)<\/dyad-rename>/g;
|
||||
let match;
|
||||
const tags: { from: string; to: string }[] = [];
|
||||
while ((match = dyadRenameRegex.exec(fullResponse)) !== null) {
|
||||
tags.push({ from: match[1], to: match[2] });
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
export function getDyadDeleteTags(fullResponse: string): string[] {
|
||||
const dyadDeleteRegex =
|
||||
/<dyad-delete path="([^"]+)"[^>]*>([\s\S]*?)<\/dyad-delete>/g;
|
||||
let match;
|
||||
const paths: string[] = [];
|
||||
while ((match = dyadDeleteRegex.exec(fullResponse)) !== null) {
|
||||
paths.push(match[1]);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function getDyadAddDependencyTags(fullResponse: string): string[] {
|
||||
const dyadAddDependencyRegex =
|
||||
/<dyad-add-dependency package="([^"]+)">[^<]*<\/dyad-add-dependency>/g;
|
||||
let match;
|
||||
const packages: string[] = [];
|
||||
while ((match = dyadAddDependencyRegex.exec(fullResponse)) !== null) {
|
||||
packages.push(match[1]);
|
||||
}
|
||||
return packages;
|
||||
}
|
||||
|
||||
export async function processFullResponseActions(
|
||||
fullResponse: string,
|
||||
chatId: number,
|
||||
{ chatSummary }: { chatSummary: string | undefined }
|
||||
): Promise<{ updatedFiles?: boolean; error?: string }> {
|
||||
// Get the app associated with the chat
|
||||
const chatWithApp = await db.query.chats.findFirst({
|
||||
where: eq(chats.id, chatId),
|
||||
with: {
|
||||
app: true,
|
||||
},
|
||||
});
|
||||
if (!chatWithApp || !chatWithApp.app) {
|
||||
console.error(`No app found for chat ID: ${chatId}`);
|
||||
return {};
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(chatWithApp.app.path);
|
||||
const writtenFiles: string[] = [];
|
||||
const renamedFiles: string[] = [];
|
||||
const deletedFiles: string[] = [];
|
||||
|
||||
try {
|
||||
// Extract all tags
|
||||
const dyadWriteTags = getDyadWriteTags(fullResponse);
|
||||
const dyadRenameTags = getDyadRenameTags(fullResponse);
|
||||
const dyadDeletePaths = getDyadDeleteTags(fullResponse);
|
||||
const dyadAddDependencyPackages = getDyadAddDependencyTags(fullResponse);
|
||||
|
||||
// If no tags to process, return early
|
||||
if (
|
||||
dyadWriteTags.length === 0 &&
|
||||
dyadRenameTags.length === 0 &&
|
||||
dyadDeletePaths.length === 0 &&
|
||||
dyadAddDependencyPackages.length === 0
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Process all file writes
|
||||
for (const tag of dyadWriteTags) {
|
||||
const filePath = tag.path;
|
||||
const content = tag.content;
|
||||
const fullFilePath = path.join(appPath, filePath);
|
||||
|
||||
// Ensure directory exists
|
||||
const dirPath = path.dirname(fullFilePath);
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
|
||||
// Write file content
|
||||
fs.writeFileSync(fullFilePath, content);
|
||||
console.log(`Successfully wrote file: ${fullFilePath}`);
|
||||
writtenFiles.push(filePath);
|
||||
}
|
||||
|
||||
// Process all file renames
|
||||
for (const tag of dyadRenameTags) {
|
||||
const fromPath = path.join(appPath, tag.from);
|
||||
const toPath = path.join(appPath, tag.to);
|
||||
|
||||
// Ensure target directory exists
|
||||
const dirPath = path.dirname(toPath);
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
|
||||
// Rename the file
|
||||
if (fs.existsSync(fromPath)) {
|
||||
fs.renameSync(fromPath, toPath);
|
||||
console.log(`Successfully renamed file: ${fromPath} -> ${toPath}`);
|
||||
renamedFiles.push(tag.to);
|
||||
|
||||
// Add the new file and remove the old one from git
|
||||
await git.add({
|
||||
fs,
|
||||
dir: appPath,
|
||||
filepath: tag.to,
|
||||
});
|
||||
try {
|
||||
await git.remove({
|
||||
fs,
|
||||
dir: appPath,
|
||||
filepath: tag.from,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`Failed to git remove old file ${tag.from}:`, error);
|
||||
// Continue even if remove fails as the file was still renamed
|
||||
}
|
||||
} else {
|
||||
console.warn(`Source file for rename does not exist: ${fromPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Process all file deletions
|
||||
for (const filePath of dyadDeletePaths) {
|
||||
const fullFilePath = path.join(appPath, filePath);
|
||||
|
||||
// Delete the file if it exists
|
||||
if (fs.existsSync(fullFilePath)) {
|
||||
fs.unlinkSync(fullFilePath);
|
||||
console.log(`Successfully deleted file: ${fullFilePath}`);
|
||||
deletedFiles.push(filePath);
|
||||
|
||||
// Remove the file from git
|
||||
try {
|
||||
await git.remove({
|
||||
fs,
|
||||
dir: appPath,
|
||||
filepath: filePath,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`Failed to git remove deleted file ${filePath}:`, error);
|
||||
// Continue even if remove fails as the file was still deleted
|
||||
}
|
||||
} else {
|
||||
console.warn(`File to delete does not exist: ${fullFilePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If we have any file changes, commit them all at once
|
||||
const hasChanges =
|
||||
writtenFiles.length > 0 ||
|
||||
renamedFiles.length > 0 ||
|
||||
deletedFiles.length > 0;
|
||||
if (hasChanges) {
|
||||
// Stage all written files
|
||||
for (const file of writtenFiles) {
|
||||
await git.add({
|
||||
fs,
|
||||
dir: appPath,
|
||||
filepath: file,
|
||||
});
|
||||
}
|
||||
|
||||
// Create commit with details of all changes
|
||||
const changes = [];
|
||||
if (writtenFiles.length > 0)
|
||||
changes.push(`wrote ${writtenFiles.length} file(s)`);
|
||||
if (renamedFiles.length > 0)
|
||||
changes.push(`renamed ${renamedFiles.length} file(s)`);
|
||||
if (deletedFiles.length > 0)
|
||||
changes.push(`deleted ${deletedFiles.length} file(s)`);
|
||||
|
||||
await git.commit({
|
||||
fs,
|
||||
dir: appPath,
|
||||
message: chatSummary
|
||||
? `[dyad] ${chatSummary} - ${changes.join(", ")}`
|
||||
: `[dyad] ${changes.join(", ")}`,
|
||||
author: {
|
||||
name: "Dyad AI",
|
||||
email: "dyad-ai@example.com",
|
||||
},
|
||||
});
|
||||
console.log(`Successfully committed changes: ${changes.join(", ")}`);
|
||||
return { updatedFiles: true };
|
||||
}
|
||||
|
||||
return {};
|
||||
} catch (error: unknown) {
|
||||
console.error("Error processing files:", error);
|
||||
return { error: (error as any).toString() };
|
||||
}
|
||||
}
|
||||
53
src/ipc/utils/file_utils.ts
Normal file
53
src/ipc/utils/file_utils.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { promises as fsPromises } from "node:fs";
|
||||
|
||||
/**
|
||||
* Recursively gets all files in a directory, excluding node_modules and .git
|
||||
* @param dir The directory to scan
|
||||
* @param baseDir The base directory for calculating relative paths
|
||||
* @returns Array of file paths relative to the base directory
|
||||
*/
|
||||
export function getFilesRecursively(dir: string, baseDir: string): string[] {
|
||||
if (!fs.existsSync(dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dirents = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
|
||||
for (const dirent of dirents) {
|
||||
const res = path.join(dir, dirent.name);
|
||||
if (dirent.isDirectory()) {
|
||||
// For directories, concat the results of recursive call
|
||||
// Exclude node_modules and .git directories
|
||||
if (dirent.name !== "node_modules" && dirent.name !== ".git") {
|
||||
files.push(...getFilesRecursively(res, baseDir));
|
||||
}
|
||||
} else {
|
||||
// For files, add the relative path
|
||||
files.push(path.relative(baseDir, res));
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function copyDirectoryRecursive(
|
||||
source: string,
|
||||
destination: string
|
||||
) {
|
||||
await fsPromises.mkdir(destination, { recursive: true });
|
||||
const entries = await fsPromises.readdir(source, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(source, entry.name);
|
||||
const destPath = path.join(destination, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await copyDirectoryRecursive(srcPath, destPath);
|
||||
} else {
|
||||
await fsPromises.copyFile(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
65
src/ipc/utils/get_model_client.ts
Normal file
65
src/ipc/utils/get_model_client.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { createGoogleGenerativeAI as createGoogle } from "@ai-sdk/google";
|
||||
import { createAnthropic } from "@ai-sdk/anthropic";
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
||||
import type { LargeLanguageModel, UserSettings } from "../../lib/schemas";
|
||||
import { PROVIDER_TO_ENV_VAR, AUTO_MODELS } from "../../constants/models";
|
||||
|
||||
export function getModelClient(
|
||||
model: LargeLanguageModel,
|
||||
settings: UserSettings
|
||||
) {
|
||||
// Handle 'auto' provider by trying each model in AUTO_MODELS until one works
|
||||
if (model.provider === "auto") {
|
||||
// Try each model in AUTO_MODELS in order until finding one with an API key
|
||||
for (const autoModel of AUTO_MODELS) {
|
||||
const apiKey =
|
||||
settings.providerSettings?.[autoModel.provider]?.apiKey ||
|
||||
process.env[PROVIDER_TO_ENV_VAR[autoModel.provider]];
|
||||
|
||||
if (apiKey) {
|
||||
console.log(
|
||||
`Using provider: ${autoModel.provider} model: ${autoModel.name}`
|
||||
);
|
||||
// Use the first model that has an API key
|
||||
return getModelClient(
|
||||
{
|
||||
provider: autoModel.provider,
|
||||
name: autoModel.name,
|
||||
} as LargeLanguageModel,
|
||||
settings
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If no models have API keys, throw an error
|
||||
throw new Error("No API keys available for any model in AUTO_MODELS");
|
||||
}
|
||||
|
||||
const apiKey =
|
||||
settings.providerSettings?.[model.provider]?.apiKey ||
|
||||
process.env[PROVIDER_TO_ENV_VAR[model.provider]];
|
||||
switch (model.provider) {
|
||||
case "openai": {
|
||||
const provider = createOpenAI({ apiKey });
|
||||
return provider(model.name);
|
||||
}
|
||||
case "anthropic": {
|
||||
const provider = createAnthropic({ apiKey });
|
||||
return provider(model.name);
|
||||
}
|
||||
case "google": {
|
||||
const provider = createGoogle({ apiKey });
|
||||
return provider(model.name);
|
||||
}
|
||||
case "openrouter": {
|
||||
const provider = createOpenRouter({ apiKey });
|
||||
return provider(model.name);
|
||||
}
|
||||
default: {
|
||||
// Ensure exhaustive check if more providers are added
|
||||
const _exhaustiveCheck: never = model.provider;
|
||||
throw new Error(`Unsupported model provider: ${model.provider}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
51
src/ipc/utils/lock_utils.ts
Normal file
51
src/ipc/utils/lock_utils.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// Track app operations that are in progress
|
||||
const appOperationLocks = new Map<number, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Acquires a lock for an app operation
|
||||
* @param appId The app ID to lock
|
||||
* @returns An object with release function and promise
|
||||
*/
|
||||
export function acquireLock(appId: number): {
|
||||
release: () => void;
|
||||
promise: Promise<void>;
|
||||
} {
|
||||
let release: () => void = () => {};
|
||||
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
release = () => {
|
||||
appOperationLocks.delete(appId);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
|
||||
appOperationLocks.set(appId, promise);
|
||||
return { release, promise };
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a function with a lock on the app ID
|
||||
* @param appId The app ID to lock
|
||||
* @param fn The function to execute with the lock
|
||||
* @returns Result of the function
|
||||
*/
|
||||
export async function withLock<T>(
|
||||
appId: number,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
// Wait for any existing operation to complete
|
||||
const existingLock = appOperationLocks.get(appId);
|
||||
if (existingLock) {
|
||||
await existingLock;
|
||||
}
|
||||
|
||||
// Acquire a new lock
|
||||
const { release, promise } = acquireLock(appId);
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
return result;
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
104
src/ipc/utils/process_manager.ts
Normal file
104
src/ipc/utils/process_manager.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { ChildProcess } from "node:child_process";
|
||||
import treeKill from "tree-kill";
|
||||
|
||||
// Define a type for the value stored in runningApps
|
||||
export interface RunningAppInfo {
|
||||
process: ChildProcess;
|
||||
processId: number;
|
||||
}
|
||||
|
||||
// Store running app processes
|
||||
export const runningApps = new Map<number, RunningAppInfo>();
|
||||
// Global counter for process IDs
|
||||
let processCounterValue = 0;
|
||||
|
||||
// Getter and setter for processCounter to allow modification from outside
|
||||
export const processCounter = {
|
||||
get value(): number {
|
||||
return processCounterValue;
|
||||
},
|
||||
set value(newValue: number) {
|
||||
processCounterValue = newValue;
|
||||
},
|
||||
increment(): number {
|
||||
return ++processCounterValue;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Kills a running process with its child processes
|
||||
* @param process The child process to kill
|
||||
* @param pid The process ID
|
||||
* @returns A promise that resolves when the process is closed or timeout
|
||||
*/
|
||||
export function killProcess(process: ChildProcess): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
// Add timeout to prevent hanging
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn(
|
||||
`Timeout waiting for process (PID: ${process.pid}) to close. Force killing may be needed.`
|
||||
);
|
||||
resolve();
|
||||
}, 5000); // 5-second timeout
|
||||
|
||||
process.on("close", (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
console.log(
|
||||
`Received 'close' event for process (PID: ${process.pid}) with code ${code}, signal ${signal}.`
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
|
||||
// Handle potential errors during kill/close sequence
|
||||
process.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error(
|
||||
`Error during stop sequence for process (PID: ${process.pid}): ${err.message}`
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
|
||||
// Ensure PID exists before attempting to kill
|
||||
if (process.pid) {
|
||||
// Use tree-kill to terminate the entire process tree
|
||||
console.log(
|
||||
`Attempting to tree-kill process tree starting at PID ${process.pid}.`
|
||||
);
|
||||
treeKill(process.pid, "SIGTERM", (err: Error | undefined) => {
|
||||
if (err) {
|
||||
console.warn(
|
||||
`tree-kill error for PID ${process.pid}: ${err.message}`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`tree-kill signal sent successfully to PID ${process.pid}.`
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn(`Cannot tree-kill process: PID is undefined.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an app from the running apps map if it's the current process
|
||||
* @param appId The app ID
|
||||
* @param process The process to check against
|
||||
*/
|
||||
export function removeAppIfCurrentProcess(
|
||||
appId: number,
|
||||
process: ChildProcess
|
||||
): void {
|
||||
const currentAppInfo = runningApps.get(appId);
|
||||
if (currentAppInfo && currentAppInfo.process === process) {
|
||||
runningApps.delete(appId);
|
||||
console.log(
|
||||
`Removed app ${appId} (processId ${currentAppInfo.processId}) from running map. Current size: ${runningApps.size}`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`App ${appId} process was already removed or replaced in running map. Ignoring.`
|
||||
);
|
||||
}
|
||||
}
|
||||
34
src/lib/chat.ts
Normal file
34
src/lib/chat.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Message } from "ai";
|
||||
import { IpcClient } from "../ipc/ipc_client";
|
||||
import type { ChatSummary } from "./schemas";
|
||||
import type { CreateAppParams, CreateAppResult } from "../ipc/ipc_types";
|
||||
|
||||
/**
|
||||
* Create a new app with an initial chat and prompt
|
||||
* @param params Object containing name, path, and initialPrompt
|
||||
* @returns The created app and chatId
|
||||
*/
|
||||
export async function createApp(
|
||||
params: CreateAppParams
|
||||
): Promise<CreateAppResult> {
|
||||
try {
|
||||
return await IpcClient.getInstance().createApp(params);
|
||||
} catch (error) {
|
||||
console.error("[CHAT] Error creating app:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all chats from the database
|
||||
* @param appId Optional app ID to filter chats by app
|
||||
* @returns Array of chat summaries with id, title, and createdAt
|
||||
*/
|
||||
export async function getAllChats(appId?: number): Promise<ChatSummary[]> {
|
||||
try {
|
||||
return await IpcClient.getInstance().getChats(appId);
|
||||
} catch (error) {
|
||||
console.error("[CHAT] Error getting all chats:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
75
src/lib/schemas.ts
Normal file
75
src/lib/schemas.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Zod schema for chat summary objects returned by the get-chats IPC
|
||||
*/
|
||||
export const ChatSummarySchema = z.object({
|
||||
id: z.number(),
|
||||
appId: z.number(),
|
||||
title: z.string().nullable(),
|
||||
createdAt: z.date(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Type derived from the ChatSummarySchema
|
||||
*/
|
||||
export type ChatSummary = z.infer<typeof ChatSummarySchema>;
|
||||
|
||||
/**
|
||||
* Zod schema for an array of chat summaries
|
||||
*/
|
||||
export const ChatSummariesSchema = z.array(ChatSummarySchema);
|
||||
|
||||
/**
|
||||
* Zod schema for model provider
|
||||
*/
|
||||
export const ModelProviderSchema = z.enum([
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google",
|
||||
"auto",
|
||||
"openrouter",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Type derived from the ModelProviderSchema
|
||||
*/
|
||||
export type ModelProvider = z.infer<typeof ModelProviderSchema>;
|
||||
|
||||
/**
|
||||
* Zod schema for large language model configuration
|
||||
*/
|
||||
export const LargeLanguageModelSchema = z.object({
|
||||
name: z.string(),
|
||||
provider: ModelProviderSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* Type derived from the LargeLanguageModelSchema
|
||||
*/
|
||||
export type LargeLanguageModel = z.infer<typeof LargeLanguageModelSchema>;
|
||||
|
||||
/**
|
||||
* Zod schema for provider settings
|
||||
*/
|
||||
export const ProviderSettingSchema = z.object({
|
||||
apiKey: z.string().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Type derived from the ProviderSettingSchema
|
||||
*/
|
||||
export type ProviderSetting = z.infer<typeof ProviderSettingSchema>;
|
||||
|
||||
/**
|
||||
* Zod schema for user settings
|
||||
*/
|
||||
export const UserSettingsSchema = z.object({
|
||||
selectedModel: LargeLanguageModelSchema,
|
||||
providerSettings: z.record(z.string(), ProviderSettingSchema),
|
||||
});
|
||||
|
||||
/**
|
||||
* Type derived from the UserSettingsSchema
|
||||
*/
|
||||
export type UserSettings = z.infer<typeof UserSettingsSchema>;
|
||||
53
src/lib/toast.ts
Normal file
53
src/lib/toast.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { toast } from "sonner";
|
||||
|
||||
/**
|
||||
* Toast utility functions for consistent notifications across the app
|
||||
*/
|
||||
|
||||
/**
|
||||
* Show a success toast
|
||||
* @param message The message to display
|
||||
*/
|
||||
export const showSuccess = (message: string) => {
|
||||
toast.success(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Show an error toast
|
||||
* @param message The error message to display
|
||||
*/
|
||||
export const showError = (message: any) => {
|
||||
toast.error(message.toString());
|
||||
console.error(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Show an info toast
|
||||
* @param message The info message to display
|
||||
*/
|
||||
export const showInfo = (message: string) => {
|
||||
toast.info(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Show a loading toast that can be updated with success/error
|
||||
* @param loadingMessage The message to show while loading
|
||||
* @param promise The promise to track
|
||||
* @param successMessage Optional success message
|
||||
* @param errorMessage Optional error message
|
||||
*/
|
||||
export const showLoading = <T>(
|
||||
loadingMessage: string,
|
||||
promise: Promise<T>,
|
||||
successMessage?: string,
|
||||
errorMessage?: string
|
||||
) => {
|
||||
return toast.promise(promise, {
|
||||
loading: loadingMessage,
|
||||
success: (data) => successMessage || "Operation completed successfully",
|
||||
error: (err) => errorMessage || `Error: ${err.message || "Unknown error"}`,
|
||||
});
|
||||
};
|
||||
|
||||
// Re-export for direct use
|
||||
export { toast };
|
||||
152
src/lib/utils.ts
Normal file
152
src/lib/utils.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a cute app name like "blue-fox" or "jumping-zebra"
|
||||
*/
|
||||
export function generateCuteAppName(): string {
|
||||
const adjectives = [
|
||||
"happy",
|
||||
"gentle",
|
||||
"brave",
|
||||
"clever",
|
||||
"swift",
|
||||
"bright",
|
||||
"calm",
|
||||
"nimble",
|
||||
"sleepy",
|
||||
"fluffy",
|
||||
"wild",
|
||||
"tiny",
|
||||
"bold",
|
||||
"wise",
|
||||
"merry",
|
||||
"quick",
|
||||
"busy",
|
||||
"silent",
|
||||
"cozy",
|
||||
"jolly",
|
||||
"playful",
|
||||
"friendly",
|
||||
"curious",
|
||||
"peaceful",
|
||||
"silly",
|
||||
"dazzling",
|
||||
"graceful",
|
||||
"elegant",
|
||||
"cosmic",
|
||||
"whispering",
|
||||
"dancing",
|
||||
"sparkling",
|
||||
"mystical",
|
||||
"vibrant",
|
||||
"radiant",
|
||||
"dreamy",
|
||||
"patient",
|
||||
"energetic",
|
||||
"vigilant",
|
||||
"sincere",
|
||||
"electric",
|
||||
"stellar",
|
||||
"lunar",
|
||||
"serene",
|
||||
"mighty",
|
||||
"magical",
|
||||
"neon",
|
||||
"azure",
|
||||
"crimson",
|
||||
"emerald",
|
||||
"golden",
|
||||
"jade",
|
||||
"crystal",
|
||||
"snuggly",
|
||||
"glowing",
|
||||
"wandering",
|
||||
"whistling",
|
||||
"bubbling",
|
||||
"floating",
|
||||
"flying",
|
||||
"hopping",
|
||||
];
|
||||
|
||||
const animals = [
|
||||
"fox",
|
||||
"panda",
|
||||
"rabbit",
|
||||
"wolf",
|
||||
"bear",
|
||||
"owl",
|
||||
"koala",
|
||||
"beaver",
|
||||
"ferret",
|
||||
"squirrel",
|
||||
"zebra",
|
||||
"tiger",
|
||||
"lynx",
|
||||
"lemur",
|
||||
"penguin",
|
||||
"otter",
|
||||
"hedgehog",
|
||||
"deer",
|
||||
"badger",
|
||||
"raccoon",
|
||||
"turtle",
|
||||
"dolphin",
|
||||
"eagle",
|
||||
"falcon",
|
||||
"parrot",
|
||||
"capybara",
|
||||
"axolotl",
|
||||
"narwhal",
|
||||
"wombat",
|
||||
"meerkat",
|
||||
"platypus",
|
||||
"mongoose",
|
||||
"chinchilla",
|
||||
"quokka",
|
||||
"alpaca",
|
||||
"chameleon",
|
||||
"ocelot",
|
||||
"manatee",
|
||||
"puffin",
|
||||
"shiba",
|
||||
"sloth",
|
||||
"gecko",
|
||||
"hummingbird",
|
||||
"mantis",
|
||||
"jellyfish",
|
||||
"pangolin",
|
||||
"okapi",
|
||||
"binturong",
|
||||
"tardigrade",
|
||||
"beluga",
|
||||
"kiwi",
|
||||
"octopus",
|
||||
"salamander",
|
||||
"seahorse",
|
||||
"kookaburra",
|
||||
"gibbon",
|
||||
"jackrabbit",
|
||||
"lobster",
|
||||
"iguana",
|
||||
"tamarin",
|
||||
"armadillo",
|
||||
"starfish",
|
||||
"walrus",
|
||||
"phoenix",
|
||||
"griffin",
|
||||
"dragon",
|
||||
"unicorn",
|
||||
"kraken",
|
||||
];
|
||||
|
||||
const randomAdjective =
|
||||
adjectives[Math.floor(Math.random() * adjectives.length)];
|
||||
const randomAnimal = animals[Math.floor(Math.random() * animals.length)];
|
||||
|
||||
return `${randomAdjective}-${randomAnimal}`;
|
||||
}
|
||||
81
src/main.ts
Normal file
81
src/main.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { app, BrowserWindow } from "electron";
|
||||
import * as path from "node:path";
|
||||
import { registerIpcHandlers } from "./ipc/ipc_host";
|
||||
import dotenv from "dotenv";
|
||||
// @ts-ignore
|
||||
import started from "electron-squirrel-startup";
|
||||
import { updateElectronApp } from "update-electron-app";
|
||||
updateElectronApp(); // additional configuration options available
|
||||
|
||||
// Load environment variables from .env file
|
||||
dotenv.config();
|
||||
|
||||
// Register IPC handlers before app is ready
|
||||
registerIpcHandlers();
|
||||
|
||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||
if (started) {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
declare global {
|
||||
const MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
|
||||
}
|
||||
|
||||
const createWindow = () => {
|
||||
// Create the browser window.
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: process.env.NODE_ENV === "development" ? 1280 : 960,
|
||||
height: 700,
|
||||
titleBarStyle: "hidden",
|
||||
titleBarOverlay: true,
|
||||
trafficLightPosition: {
|
||||
x: 10,
|
||||
y: 8,
|
||||
},
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
// transparent: true,
|
||||
},
|
||||
// backgroundColor: "#00000001",
|
||||
// frame: false,
|
||||
});
|
||||
// and load the index.html of the app.
|
||||
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
||||
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
|
||||
} else {
|
||||
mainWindow.loadFile(
|
||||
path.join(__dirname, "../renderer/main_window/index.html")
|
||||
);
|
||||
}
|
||||
|
||||
// Open the DevTools.
|
||||
mainWindow.webContents.openDevTools();
|
||||
};
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.on("ready", createWindow);
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
// for applications and their menu bar to stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
// On OS X it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
// In this file you can include the rest of your app's specific main process
|
||||
// code. You can also put them in separate files and import them here.
|
||||
15
src/main.tsx
Normal file
15
src/main.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import { router } from "./router";
|
||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
import { Toaster } from "sonner";
|
||||
import "./styles/globals.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<TooltipProvider>
|
||||
<RouterProvider router={router} />
|
||||
</TooltipProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
51
src/main/settings.ts
Normal file
51
src/main/settings.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getUserDataPath } from "../paths/paths";
|
||||
import { UserSettingsSchema, type UserSettings } from "../lib/schemas";
|
||||
|
||||
const DEFAULT_SETTINGS: UserSettings = {
|
||||
selectedModel: {
|
||||
name: "auto",
|
||||
provider: "auto",
|
||||
},
|
||||
providerSettings: {},
|
||||
};
|
||||
|
||||
const SETTINGS_FILE = "user-settings.json";
|
||||
|
||||
function getSettingsFilePath(): string {
|
||||
return path.join(getUserDataPath(), SETTINGS_FILE);
|
||||
}
|
||||
|
||||
export function readSettings(): UserSettings {
|
||||
try {
|
||||
const filePath = getSettingsFilePath();
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, JSON.stringify(DEFAULT_SETTINGS, null, 2));
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
const rawSettings = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
// Validate and merge with defaults
|
||||
const validatedSettings = UserSettingsSchema.parse({
|
||||
...DEFAULT_SETTINGS,
|
||||
...rawSettings,
|
||||
});
|
||||
return validatedSettings;
|
||||
} catch (error) {
|
||||
console.error("Error reading settings:", error);
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSettings(settings: Partial<UserSettings>): void {
|
||||
try {
|
||||
const filePath = getSettingsFilePath();
|
||||
const currentSettings = readSettings();
|
||||
const newSettings = { ...currentSettings, ...settings };
|
||||
// Validate before writing
|
||||
const validatedSettings = UserSettingsSchema.parse(newSettings);
|
||||
fs.writeFileSync(filePath, JSON.stringify(validatedSettings, null, 2));
|
||||
} catch (error) {
|
||||
console.error("Error writing settings:", error);
|
||||
}
|
||||
}
|
||||
454
src/pages/app-details.tsx
Normal file
454
src/pages/app-details.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { appBasePathAtom, appsListAtom } from "@/atoms/appAtoms";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { useLoadApps } from "@/hooks/useLoadApps";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ArrowLeft,
|
||||
MoreVertical,
|
||||
ArrowRight,
|
||||
MessageCircle,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export default function AppDetailsPage() {
|
||||
const navigate = useNavigate();
|
||||
const search = useSearch({ from: "/app-details" as const });
|
||||
const [appsList] = useAtom(appsListAtom);
|
||||
const { refreshApps } = useLoadApps();
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [isRenameConfirmDialogOpen, setIsRenameConfirmDialogOpen] =
|
||||
useState(false);
|
||||
const [newAppName, setNewAppName] = useState("");
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [isRenameFolderDialogOpen, setIsRenameFolderDialogOpen] =
|
||||
useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
const [isRenamingFolder, setIsRenamingFolder] = useState(false);
|
||||
const appBasePath = useAtomValue(appBasePathAtom);
|
||||
|
||||
// Get the appId from search params and find the corresponding app
|
||||
const appId = search.appId ? Number(search.appId) : null;
|
||||
const selectedApp = appId ? appsList.find((app) => app.id === appId) : null;
|
||||
|
||||
const handleDeleteApp = async () => {
|
||||
if (!appId) return;
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await IpcClient.getInstance().deleteApp(appId);
|
||||
setIsDeleteDialogOpen(false);
|
||||
await refreshApps();
|
||||
navigate({ to: "/", search: {} });
|
||||
} catch (error) {
|
||||
console.error("Failed to delete app:", error);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenRenameDialog = () => {
|
||||
if (selectedApp) {
|
||||
setNewAppName(selectedApp.name);
|
||||
setIsRenameDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenRenameFolderDialog = () => {
|
||||
if (selectedApp) {
|
||||
setNewFolderName(selectedApp.path.split("/").pop() || selectedApp.path);
|
||||
setIsRenameFolderDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameApp = async (renameFolder: boolean) => {
|
||||
if (!appId || !selectedApp || !newAppName.trim()) return;
|
||||
|
||||
try {
|
||||
setIsRenaming(true);
|
||||
|
||||
// Determine the new path based on user's choice
|
||||
const appPath = renameFolder ? newAppName : selectedApp.path;
|
||||
|
||||
await IpcClient.getInstance().renameApp({
|
||||
appId,
|
||||
appName: newAppName,
|
||||
appPath,
|
||||
});
|
||||
|
||||
setIsRenameDialogOpen(false);
|
||||
setIsRenameConfirmDialogOpen(false);
|
||||
await refreshApps();
|
||||
} catch (error) {
|
||||
console.error("Failed to rename app:", error);
|
||||
alert(
|
||||
`Error renaming app: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
} finally {
|
||||
setIsRenaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameFolderOnly = async () => {
|
||||
if (!appId || !selectedApp || !newFolderName.trim()) return;
|
||||
|
||||
try {
|
||||
setIsRenamingFolder(true);
|
||||
|
||||
await IpcClient.getInstance().renameApp({
|
||||
appId,
|
||||
appName: selectedApp.name, // Keep the app name the same
|
||||
appPath: newFolderName, // Change only the folder path
|
||||
});
|
||||
|
||||
setIsRenameFolderDialogOpen(false);
|
||||
await refreshApps();
|
||||
} catch (error) {
|
||||
console.error("Failed to rename folder:", error);
|
||||
alert(
|
||||
`Error renaming folder: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
} finally {
|
||||
setIsRenamingFolder(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedApp) {
|
||||
return (
|
||||
<div className="relative min-h-screen p-8">
|
||||
<Button
|
||||
onClick={() => navigate({ to: "/", search: {} })}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="absolute top-4 left-4 flex items-center gap-2 bg-(--background-lightest) py-5"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<h2 className="text-2xl font-bold mb-4">App not found</h2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen p-8 w-full">
|
||||
<Button
|
||||
onClick={() => navigate({ to: "/", search: {} })}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="absolute top-4 left-4 flex items-center gap-2 bg-(--background-lightest) py-5"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
|
||||
<div className="w-full max-w-2xl mx-auto mt-16 p-8 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-md relative">
|
||||
<div className="flex items-center mb-6">
|
||||
<h2 className="text-3xl font-bold">{selectedApp.name}</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-2 p-1 h-auto"
|
||||
onClick={handleOpenRenameDialog}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Overflow Menu in top right */}
|
||||
<div className="absolute top-4 right-4">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48" align="end">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Button onClick={handleOpenRenameFolderDialog} variant="ghost">
|
||||
Rename folder
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
variant="ghost"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 text-base mb-8">
|
||||
<div>
|
||||
<span className="block text-gray-500 dark:text-gray-400 mb-1 text-base">
|
||||
Created
|
||||
</span>
|
||||
<span>{new Date().toLocaleString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-gray-500 dark:text-gray-400 mb-1 text-base">
|
||||
Last Updated
|
||||
</span>
|
||||
<span>{new Date().toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="block text-gray-500 dark:text-gray-400 mb-1 text-base">
|
||||
Path
|
||||
</span>
|
||||
<span>
|
||||
{appBasePath.replace("$APP_BASE_PATH", selectedApp.path)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 flex gap-4">
|
||||
<Button
|
||||
onClick={() =>
|
||||
appId && navigate({ to: "/chat", search: { id: appId } })
|
||||
}
|
||||
className="cursor-pointer w-full py-6 flex justify-center items-center gap-2 text-lg"
|
||||
size="lg"
|
||||
>
|
||||
Open in Chat
|
||||
<MessageCircle className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Rename Dialog */}
|
||||
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename App</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={newAppName}
|
||||
onChange={(e) => setNewAppName(e.target.value)}
|
||||
placeholder="Enter new app name"
|
||||
className="my-4"
|
||||
autoFocus
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsRenameDialogOpen(false)}
|
||||
disabled={isRenaming}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsRenameDialogOpen(false);
|
||||
setIsRenameConfirmDialogOpen(true);
|
||||
}}
|
||||
disabled={isRenaming || !newAppName.trim()}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Rename Folder Dialog */}
|
||||
<Dialog
|
||||
open={isRenameFolderDialogOpen}
|
||||
onOpenChange={setIsRenameFolderDialogOpen}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename app folder</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will change only the folder name, not the app name.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
placeholder="Enter new folder name"
|
||||
className="my-4"
|
||||
autoFocus
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsRenameFolderDialogOpen(false)}
|
||||
disabled={isRenamingFolder}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRenameFolderOnly}
|
||||
disabled={isRenamingFolder || !newFolderName.trim()}
|
||||
>
|
||||
{isRenamingFolder ? (
|
||||
<>
|
||||
<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>
|
||||
Renaming...
|
||||
</>
|
||||
) : (
|
||||
"Rename Folder"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Rename Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={isRenameConfirmDialogOpen}
|
||||
onOpenChange={setIsRenameConfirmDialogOpen}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
How would you like to rename "{selectedApp.name}"?
|
||||
</DialogTitle>
|
||||
<DialogDescription>Choose an option:</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 my-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start p-4 h-auto relative"
|
||||
onClick={() => handleRenameApp(true)}
|
||||
disabled={isRenaming}
|
||||
>
|
||||
<div className="absolute top-2 right-2">
|
||||
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2 py-0.5 rounded dark:bg-blue-900 dark:text-blue-300">
|
||||
Recommended
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Rename app and folder</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Renames the folder to match the new app name.
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start p-4 h-auto"
|
||||
onClick={() => handleRenameApp(false)}
|
||||
disabled={isRenaming}
|
||||
>
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Rename app only</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
The folder name will remain the same.
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsRenameConfirmDialogOpen(false)}
|
||||
disabled={isRenaming}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete "{selectedApp.name}"?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action is irreversible. All app files and chat history will
|
||||
be permanently deleted.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsDeleteDialogOpen(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteApp}
|
||||
disabled={isDeleting}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin h-4 w-4 text-white"
|
||||
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>
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
"Delete App"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
src/pages/chat.tsx
Normal file
67
src/pages/chat.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
PanelGroup,
|
||||
Panel,
|
||||
PanelResizeHandle,
|
||||
type ImperativePanelHandle,
|
||||
} from "react-resizable-panels";
|
||||
import { ChatPanel } from "../components/ChatPanel";
|
||||
import { PreviewPanel } from "../components/preview_panel/PreviewPanel";
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAtom } from "jotai";
|
||||
import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
|
||||
|
||||
export default function ChatPage() {
|
||||
const { id } = useSearch({ from: "/chat" });
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useAtom(isPreviewOpenAtom);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPreviewOpen) {
|
||||
ref.current?.expand();
|
||||
} else {
|
||||
ref.current?.collapse();
|
||||
}
|
||||
}, [isPreviewOpen]);
|
||||
const ref = useRef<ImperativePanelHandle>(null);
|
||||
|
||||
return (
|
||||
<PanelGroup autoSaveId="persistence" direction="horizontal">
|
||||
<Panel id="chat-panel" minSize={30}>
|
||||
<div className="h-full w-full">
|
||||
<ChatPanel
|
||||
chatId={id}
|
||||
isPreviewOpen={isPreviewOpen}
|
||||
onTogglePreview={() => {
|
||||
setIsPreviewOpen(!isPreviewOpen);
|
||||
if (isPreviewOpen) {
|
||||
ref.current?.collapse();
|
||||
} else {
|
||||
ref.current?.expand();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<>
|
||||
<PanelResizeHandle
|
||||
onDragging={(e) => setIsResizing(e)}
|
||||
className="w-1 bg-gray-200 hover:bg-gray-300 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors cursor-col-resize"
|
||||
/>
|
||||
<Panel
|
||||
collapsible
|
||||
ref={ref}
|
||||
id="preview-panel"
|
||||
minSize={20}
|
||||
className={cn(
|
||||
!isResizing && "transition-all duration-100 ease-in-out"
|
||||
)}
|
||||
>
|
||||
<PreviewPanel />
|
||||
</Panel>
|
||||
</>
|
||||
</PanelGroup>
|
||||
);
|
||||
}
|
||||
183
src/pages/home.tsx
Normal file
183
src/pages/home.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { chatInputValueAtom } from "../atoms/chatAtoms";
|
||||
import { selectedAppIdAtom, appsListAtom } from "@/atoms/appAtoms";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { generateCuteAppName } from "@/lib/utils";
|
||||
import { useLoadApps } from "@/hooks/useLoadApps";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { SetupBanner } from "@/components/SetupBanner";
|
||||
import { ChatInput } from "@/components/chat/ChatInput";
|
||||
import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
|
||||
export default function HomePage() {
|
||||
const [inputValue, setInputValue] = useAtom(chatInputValueAtom);
|
||||
const navigate = useNavigate();
|
||||
const search = useSearch({ from: "/" });
|
||||
const [appsList] = useAtom(appsListAtom);
|
||||
const setSelectedAppId = useSetAtom(selectedAppIdAtom);
|
||||
const { refreshApps } = useLoadApps();
|
||||
const { isAnyProviderSetup } = useSettings();
|
||||
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { streamMessage } = useStreamChat();
|
||||
|
||||
// Get the appId from search params
|
||||
const appId = search.appId ? Number(search.appId) : null;
|
||||
|
||||
// Redirect to app details page if appId is present
|
||||
useEffect(() => {
|
||||
if (appId) {
|
||||
navigate({ to: "/app-details", search: { appId } });
|
||||
}
|
||||
}, [appId, navigate]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!inputValue.trim()) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
// Create the chat and navigate
|
||||
const result = await IpcClient.getInstance().createApp({
|
||||
name: generateCuteAppName(),
|
||||
path: "./apps/foo",
|
||||
});
|
||||
|
||||
// Add a 2-second timeout *after* the streamMessage call
|
||||
// This makes the loading UI feel less janky.
|
||||
streamMessage({ prompt: inputValue, chatId: result.chatId });
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
setInputValue("");
|
||||
setSelectedAppId(result.app.id);
|
||||
setIsPreviewOpen(false);
|
||||
refreshApps();
|
||||
navigate({ to: "/chat", search: { id: result.chatId } });
|
||||
} catch (error) {
|
||||
console.error("Failed to create chat:", error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Loading overlay
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center max-w-3xl m-auto p-8">
|
||||
<div className="w-full flex flex-col items-center">
|
||||
<div className="relative w-24 h-24 mb-8">
|
||||
<div className="absolute top-0 left-0 w-full h-full border-8 border-gray-200 dark:border-gray-700 rounded-full"></div>
|
||||
<div className="absolute top-0 left-0 w-full h-full border-8 border-t-(--primary) rounded-full animate-spin"></div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-2 text-gray-800 dark:text-gray-200">
|
||||
Building your app
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-center max-w-md mb-8">
|
||||
We're setting up your app with AI magic. <br />
|
||||
This might take a moment...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center max-w-3xl m-auto p-8">
|
||||
<h1 className="text-6xl font-bold mb-12 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>
|
||||
|
||||
{!isAnyProviderSetup() && <SetupBanner />}
|
||||
|
||||
<div className="w-full">
|
||||
<ChatInput onSubmit={handleSubmit} />
|
||||
|
||||
<div className="flex flex-wrap gap-4 mt-4">
|
||||
{[
|
||||
{
|
||||
icon: (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
label: "TODO list app",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1h2a1 1 0 001-1v-7m-6 0a1 1 0 00-1 1v3"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
label: "Landing Page",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
label: "Sign Up Form",
|
||||
},
|
||||
].map((item, index) => (
|
||||
<button
|
||||
type="button"
|
||||
key={index}
|
||||
onClick={() => setInputValue(`Build me a ${item.label}`)}
|
||||
className="flex items-center gap-3 px-4 py-2 rounded-xl border border-gray-200
|
||||
bg-white/50 backdrop-blur-sm
|
||||
transition-all duration-200
|
||||
hover:bg-white hover:shadow-md hover:border-gray-300
|
||||
active:scale-[0.98]
|
||||
dark:bg-gray-800/50 dark:border-gray-700
|
||||
dark:hover:bg-gray-800 dark:hover:border-gray-600"
|
||||
>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
src/pages/settings.tsx
Normal file
121
src/pages/settings.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "../contexts/ThemeContext";
|
||||
import { ProviderSettingsGrid } from "@/components/ProviderSettings";
|
||||
import ConfirmationDialog from "@/components/ConfirmationDialog";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { showSuccess, showError } from "@/lib/toast";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const handleResetEverything = async () => {
|
||||
setIsResetting(true);
|
||||
try {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
const result = await ipcClient.resetAll();
|
||||
if (result.success) {
|
||||
showSuccess("Successfully reset everything. Restart the application.");
|
||||
} else {
|
||||
showError(result.message || "Failed to reset everything.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error resetting:", error);
|
||||
showError(
|
||||
error instanceof Error ? error.message : "An unknown error occurred"
|
||||
);
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
setIsResetDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-8 text-gray-900 dark:text-white">
|
||||
Settings
|
||||
</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||
Appearance
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Theme
|
||||
</label>
|
||||
|
||||
<div className="relative bg-gray-100 dark:bg-gray-700 rounded-lg p-1 flex">
|
||||
{(["system", "light", "dark"] as const).map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
onClick={() => setTheme(option)}
|
||||
className={`
|
||||
px-4 py-1.5 text-sm font-medium rounded-md
|
||||
transition-all duration-200
|
||||
${
|
||||
theme === option
|
||||
? "bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm"
|
||||
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{option.charAt(0).toUpperCase() + option.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm">
|
||||
<ProviderSettingsGrid configuredProviders={[]} />
|
||||
</div>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-red-200 dark:border-red-800">
|
||||
<h2 className="text-lg font-medium text-red-600 dark:text-red-400 mb-4">
|
||||
Danger Zone
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Reset Everything
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
This will delete all your apps, chats, and settings. This
|
||||
action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsResetDialogOpen(true)}
|
||||
disabled={isResetting}
|
||||
className="rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isResetting ? "Resetting..." : "Reset Everything"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationDialog
|
||||
isOpen={isResetDialogOpen}
|
||||
title="Reset Everything"
|
||||
message="Are you sure you want to reset everything? This will delete all your apps, chats, and settings. This action cannot be undone."
|
||||
confirmText="Reset Everything"
|
||||
cancelText="Cancel"
|
||||
onConfirm={handleResetEverything}
|
||||
onCancel={() => setIsResetDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/paths/paths.ts
Normal file
40
src/paths/paths.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
export function getDyadAppPath(appPath: string): string {
|
||||
return path.join(os.homedir(), "dyad-apps", appPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the user data path, handling both Electron and non-Electron environments
|
||||
* In Electron: returns the app's userData directory
|
||||
* In non-Electron: returns "./userData" in the current directory
|
||||
*/
|
||||
|
||||
export function getUserDataPath(): string {
|
||||
const electron = getElectron();
|
||||
|
||||
// When running in Electron and app is ready
|
||||
if (electron?.app?.isReady() && process.env.NODE_ENV !== "development") {
|
||||
return electron.app.getPath("userData");
|
||||
}
|
||||
|
||||
// For development or when the Electron app object isn't available
|
||||
return path.resolve("./userData");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a reference to electron in a way that won't break in non-electron environments
|
||||
*/
|
||||
export function getElectron(): typeof import("electron") | undefined {
|
||||
let electron: typeof import("electron") | undefined;
|
||||
try {
|
||||
// Check if we're in an Electron environment
|
||||
if (process.versions.electron) {
|
||||
electron = require("electron");
|
||||
}
|
||||
} catch (e) {
|
||||
// Not in Electron environment
|
||||
}
|
||||
return electron;
|
||||
}
|
||||
78
src/preload.ts
Normal file
78
src/preload.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
// See the Electron documentation for details on how to use preload scripts:
|
||||
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
|
||||
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
|
||||
// Whitelist of valid channels
|
||||
const validInvokeChannels = [
|
||||
"chat:add-dep",
|
||||
"chat:message",
|
||||
"chat:cancel",
|
||||
"chat:stream",
|
||||
"create-chat",
|
||||
"create-app",
|
||||
"get-chat",
|
||||
"get-chats",
|
||||
"list-apps",
|
||||
"get-app",
|
||||
"edit-app-file",
|
||||
"read-app-file",
|
||||
"run-app",
|
||||
"stop-app",
|
||||
"restart-app",
|
||||
"list-versions",
|
||||
"revert-version",
|
||||
"checkout-version",
|
||||
"delete-app",
|
||||
"rename-app",
|
||||
"get-user-settings",
|
||||
"set-user-settings",
|
||||
"get-env-vars",
|
||||
"open-external-url",
|
||||
"reset-all",
|
||||
] as const;
|
||||
|
||||
// Add valid receive channels
|
||||
const validReceiveChannels = [
|
||||
"chat:response:chunk",
|
||||
"chat:response:end",
|
||||
"chat:response:error",
|
||||
"app:output",
|
||||
] as const;
|
||||
|
||||
type ValidInvokeChannel = (typeof validInvokeChannels)[number];
|
||||
type ValidReceiveChannel = (typeof validReceiveChannels)[number];
|
||||
|
||||
// Expose protected methods that allow the renderer process to use
|
||||
// the ipcRenderer without exposing the entire object
|
||||
contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer: {
|
||||
invoke: (channel: ValidInvokeChannel, ...args: unknown[]) => {
|
||||
if (validInvokeChannels.includes(channel)) {
|
||||
return ipcRenderer.invoke(channel, ...args);
|
||||
}
|
||||
throw new Error(`Invalid channel: ${channel}`);
|
||||
},
|
||||
on: (
|
||||
channel: ValidReceiveChannel,
|
||||
listener: (...args: unknown[]) => void
|
||||
) => {
|
||||
if (validReceiveChannels.includes(channel)) {
|
||||
const subscription = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
...args: unknown[]
|
||||
) => listener(...args);
|
||||
ipcRenderer.on(channel, subscription);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(channel, subscription);
|
||||
};
|
||||
}
|
||||
throw new Error(`Invalid channel: ${channel}`);
|
||||
},
|
||||
removeAllListeners: (channel: ValidReceiveChannel) => {
|
||||
if (validReceiveChannels.includes(channel)) {
|
||||
ipcRenderer.removeAllListeners(channel);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
279
src/prompts/system_prompt.ts
Normal file
279
src/prompts/system_prompt.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
export const SYSTEM_PROMPT = `
|
||||
<role> You are Dyad, an AI editor that creates and modifies web applications. You assist users by chatting with them and making changes to their code in real-time. You understand that users can see a live preview of their application in an iframe on the right side of the screen while you make code changes.
|
||||
Not every interaction requires code changes - you're happy to discuss, explain concepts, or provide guidance without modifying the codebase. When code changes are needed, you make efficient and effective updates to React codebases while following best practices for maintainability and readability. You take pride in keeping things simple and elegant. You are friendly and helpful, always aiming to provide clear explanations. </role>
|
||||
|
||||
|
||||
# Guidelines
|
||||
|
||||
Always reply to the user in the same language they are using.
|
||||
|
||||
- Use <dyad-chat-summary> for setting the chat summary (put this at the end). The chat summary should be less than a sentence, but more than a few words. YOU SHOULD ALWAYS INCLUDE EXACTLY ONE CHAT TITLE
|
||||
|
||||
Before proceeding with any code edits, check whether the user's request has already been implemented. If it has, inform the user without making any changes.
|
||||
|
||||
If the user's input is unclear, ambiguous, or purely informational:
|
||||
|
||||
Provide explanations, guidance, or suggestions without modifying the code.
|
||||
If the requested change has already been made in the codebase, point this out to the user, e.g., "This feature is already implemented as described."
|
||||
Respond using regular markdown formatting, including for code.
|
||||
Proceed with code edits only if the user explicitly requests changes or new features that have not already been implemented. Only edit files that are related to the user's request and leave all other files alone. Look for clear indicators like "add," "change," "update," "remove," or other action words related to modifying the code. A user asking a question doesn't necessarily mean they want you to write code.
|
||||
|
||||
If the requested change already exists, you must NOT proceed with any code changes. Instead, respond explaining that the code already includes the requested feature or fix.
|
||||
If new code needs to be written (i.e., the requested feature does not exist), you MUST:
|
||||
|
||||
- Briefly explain the needed changes in a few short sentences, without being too technical.
|
||||
- Use <dyad-write> for creating or updating files. Try to create small, focused files that will be easy to maintain. Use only one <dyad-write> block per file. Do not forget to close the dyad-write tag after writing the file. If you do NOT need to change a file, then do not use the <dyad-write> tag.
|
||||
- Use <dyad-rename> for renaming files.
|
||||
- Use <dyad-delete> for removing files.
|
||||
- Use <dyad-add-dependency> for installing packages. IF you need to install a package, then STOP. The user will install the package and then continue the conversation.
|
||||
- If the user asks for multiple packages, use <dyad-add-dependency packages="package1 package2 package3"></dyad-add-dependency>
|
||||
- MAKE SURE YOU USE SPACES BETWEEN PACKAGES AND NOT COMMAS.
|
||||
- Look carefully at all imports and ensure the files you're importing are present. If any packages need to be installed, use <dyad-add-dependency>.
|
||||
- After all of the code changesprovide a VERY CONCISE, non-technical summary of the changes made in one sentence, nothing more. This summary should be easy for non-technical users to understand. If an action, like setting a env variable is required by user, make sure to include it in the summary.
|
||||
|
||||
|
||||
Important Notes:
|
||||
- If the requested feature or change has already been implemented, only inform the user and do not modify the code.
|
||||
- Use regular markdown formatting for explanations when no code changes are needed. Only use <dyad-write>, <dyad-rename>, <dyad-delete>, and <dyad-add-dependency>.
|
||||
|
||||
# Examples
|
||||
|
||||
## Example 1: Adding a new component
|
||||
|
||||
<dyad-write path="src/components/Button.jsx" description="Creating a new Button component with Tailwind styling">
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
|
||||
const Button = ({ children, variant = 'primary', onClick, disabled = false }) => {
|
||||
const baseClasses = "px-4 py-2 rounded-md font-medium transition-colors";
|
||||
|
||||
const variantClasses = {
|
||||
primary: "bg-blue-600 hover:bg-blue-700 text-white",
|
||||
secondary: "bg-gray-200 hover:bg-gray-300 text-gray-800",
|
||||
danger: "bg-red-600 hover:bg-red-700 text-white"
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
</dyad-write>
|
||||
|
||||
<dyad-write path="src/App.jsx" description="Updating the App.jsx file to use the new Button component.">
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import Button from './components/Button';
|
||||
|
||||
|
||||
function App() {
|
||||
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h1 className="text-2xl font-bold mb-4">My Application</h1>
|
||||
|
||||
<div className="space-x-2">
|
||||
<Button onClick={() => console.log('Primary clicked')}>Primary Button</Button>
|
||||
<Button variant="secondary" onClick={() => console.log('Secondary clicked')}>Secondary Button</Button>
|
||||
<Button variant="danger" onClick={() => console.log('Danger clicked')}>Danger Button</Button>
|
||||
</div>
|
||||
|
||||
{/* ... keep existing code (rest of the component) */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
</dyad-write>
|
||||
<dyad-chat-summary>Adding a new component</dyad-chat-summary>
|
||||
|
||||
## Example 2: Installing a package and creating a Toast notification system
|
||||
|
||||
### User prompt
|
||||
|
||||
I want to add a toast
|
||||
### System response
|
||||
|
||||
OK first, you need to install the package.
|
||||
<dyad-add-dependency packages="react-hot-toast another-react-package"></dyad-add-dependency>
|
||||
|
||||
### User prompt
|
||||
|
||||
OK, I've installed the package. Now, I want to create a ToastProvider component.
|
||||
|
||||
### System response
|
||||
|
||||
<dyad-write path="src/components/ToastProvider.jsx" description="Creating a ToastProvider component.">
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
const ToastProvider = () => { <Toaster/> };
|
||||
export default ToastProvider;
|
||||
</dyad-write>
|
||||
|
||||
<dyad-write path="src/main.jsx" description="Updating the main file to include the ToastProvider.">
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import ToastProvider from './components/ToastProvider';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<ToastProvider />
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
</dyad-write>
|
||||
|
||||
<dyad-write path="src/utils/toast.js" description="Creating a utility file to make using toasts easier.">
|
||||
"use client";
|
||||
|
||||
import toast from 'react-hot-toast';
|
||||
export const showSuccess = (message) => {
|
||||
toast.success(message);
|
||||
};
|
||||
export const showError = (message) => {
|
||||
toast.error(message);
|
||||
};
|
||||
export const showLoading = (message) => {
|
||||
return toast.loading(message);
|
||||
};
|
||||
export const dismissToast = (toastId) => {
|
||||
toast.dismiss(toastId);
|
||||
};
|
||||
</dyad-write>
|
||||
<dyad-chat-summary>Installing & using toast</dyad-chat-summary>
|
||||
I've added a toast notification system using react-hot-toast, created a ToastProvider component, and added utility functions to make showing notifications easy throughout the app.
|
||||
|
||||
## Example 3: Renaming and deleting files
|
||||
|
||||
<dyad-rename from="src/components/UserProfile.jsx" to="src/components/ProfileCard.jsx"></dyad-rename>
|
||||
|
||||
<dyad-write path="src/components/ProfileCard.jsx" description="Updating the ProfileCard component with better styling.">
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { User } from 'lucide-react';
|
||||
|
||||
const ProfileCard = ({ user }) => {
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="flex items-center">
|
||||
{user.avatar ? (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.name}
|
||||
className="w-12 h-12 rounded-full mr-4"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 bg-gray-200 rounded-full flex items-center justify-center mr-4">
|
||||
<User className="text-gray-500" size={24} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-medium text-lg">{user.name}</h3>
|
||||
<p className="text-gray-500">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ... keep existing code (user details section) */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileCard;
|
||||
</dyad-write>
|
||||
|
||||
<dyad-delete path="src/components/Analytics.jsx"></dyad-delete>
|
||||
|
||||
<dyad-write path="src/pages/Dashboard.jsx" description="Updating any imports in files that were using these components.">
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import ProfileCard from '../components/ProfileCard';
|
||||
|
||||
const Dashboard = () => {
|
||||
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
|
||||
|
||||
<ProfileCard user={currentUser} />
|
||||
|
||||
{/* ... keep existing code (rest of dashboard content) */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
</dyad-write>
|
||||
<dyad-chat-summary>Renaming profile file</dyad-chat-summary>
|
||||
I've renamed the UserProfile component to ProfileCard, updated its styling, removed an unused Analytics component, and updated imports in the Dashboard page.
|
||||
|
||||
# Additional Guidelines
|
||||
|
||||
All edits you make on the codebase will directly be built and rendered, therefore you should NEVER make partial changes like:
|
||||
|
||||
letting the user know that they should implement some components
|
||||
partially implement features
|
||||
refer to non-existing files. All imports MUST exist in the codebase.
|
||||
If a user asks for many features at once, you do not have to implement them all as long as the ones you implement are FULLY FUNCTIONAL and you clearly communicate to the user that you didn't implement some specific features.
|
||||
|
||||
Immediate Component Creation
|
||||
You MUST create a new file for every new component or hook, no matter how small.
|
||||
Never add new components to existing files, even if they seem related.
|
||||
Aim for components that are 50 lines of code or less.
|
||||
Continuously be ready to refactor files that are getting too large. When they get too large, ask the user if they want you to refactor them.
|
||||
|
||||
Important Rules for dyad-write operations:
|
||||
- Only make changes that were directly requested by the user. Everything else in the files must stay exactly as it was.
|
||||
- Always specify the correct file path when using dyad-write.
|
||||
- Ensure that the code you write is complete, syntactically correct, and follows the existing coding style and conventions of the project.
|
||||
- Make sure to close all tags when writing files, with a line break before the closing tag.
|
||||
- IMPORTANT: Only use ONE <dyad-write> block per file that you write!
|
||||
- Prioritize creating small, focused files and components.
|
||||
- do NOT be lazy and ALWAYS write the entire file. It needs to be a complete file.
|
||||
|
||||
Coding guidelines
|
||||
- ALWAYS generate responsive designs.
|
||||
- Use toasts components to inform the user about important events.
|
||||
- Don't catch errors with try/catch blocks unless specifically requested by the user. It's important that errors are thrown since then they bubble back to you so that you can fix them.
|
||||
|
||||
Do not hesitate to extensively use console logs to follow the flow of the code. This will be very helpful when debugging.
|
||||
DO NOT OVERENGINEER THE CODE. You take great pride in keeping things simple and elegant. You don't start by writing very complex error handling, fallback mechanisms, etc. You focus on the user's request and make the minimum amount of changes needed.
|
||||
DON'T DO MORE THAN WHAT THE USER ASKS FOR.
|
||||
|
||||
# Tech Stack
|
||||
- You are building a React application.
|
||||
- Use TypeScript.
|
||||
- Use React Router. KEEP the routes in src/App.tsx
|
||||
- Always put source code in the src folder.
|
||||
- Put pages into src/pages/
|
||||
- Put components into src/components/
|
||||
- The main page (default page) is src/pages/Index.tsx
|
||||
- UPDATE the main page to include the new components. OTHERWISE, the user can NOT see any components!
|
||||
- ALWAYS try to use the shadcn/ui library.
|
||||
- Tailwind CSS: always use Tailwind CSS for styling components. Utilize Tailwind classes extensively for layout, spacing, colors, and other design aspects.
|
||||
|
||||
|
||||
Available packages and libraries:
|
||||
- The lucide-react package is installed for icons.
|
||||
- You ALREADY have ALL the shadcn/ui components and their dependenciesinstalled. So you don't need to install them again.
|
||||
- You have ALL the necessary Radix UI components installed.
|
||||
- Use prebuilt components from the shadcn/ui library after importing them. Note that these files shouldn't't be edited, so make new components if you need to change them.
|
||||
|
||||
`;
|
||||
9
src/renderer.tsx
Normal file
9
src/renderer.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
44
src/router.ts
Normal file
44
src/router.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { createRouter } from "@tanstack/react-router";
|
||||
import { rootRoute } from "./routes/root";
|
||||
import { homeRoute } from "./routes/home";
|
||||
import { chatRoute } from "./routes/chat";
|
||||
import { settingsRoute } from "./routes/settings";
|
||||
import { providerSettingsRoute } from "./routes/settings/providers/$provider";
|
||||
import { appDetailsRoute } from "./routes/app-details";
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
homeRoute,
|
||||
chatRoute,
|
||||
appDetailsRoute,
|
||||
settingsRoute.addChildren([providerSettingsRoute]),
|
||||
]);
|
||||
|
||||
// src/components/NotFoundRedirect.tsx
|
||||
import * as React from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
export function NotFoundRedirect() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
React.useEffect(() => {
|
||||
// Navigate to the main route ('/') immediately on mount
|
||||
// 'replace: true' prevents the invalid URL from being added to browser history
|
||||
navigate({ to: "/", replace: true });
|
||||
}, [navigate]); // Dependency array ensures this runs only once
|
||||
|
||||
// Optionally render null or a loading indicator while redirecting
|
||||
// The redirect is usually very fast, so null is often fine.
|
||||
return null;
|
||||
// Or: return <div>Redirecting...</div>;
|
||||
}
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
defaultNotFoundComponent: NotFoundRedirect,
|
||||
});
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
13
src/routes/app-details.tsx
Normal file
13
src/routes/app-details.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { rootRoute } from "./root";
|
||||
import AppDetailsPage from "../pages/app-details";
|
||||
import { z } from "zod";
|
||||
|
||||
export const appDetailsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/app-details",
|
||||
component: AppDetailsPage,
|
||||
validateSearch: z.object({
|
||||
appId: z.number().optional(),
|
||||
}),
|
||||
});
|
||||
13
src/routes/chat.tsx
Normal file
13
src/routes/chat.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { rootRoute } from "./root";
|
||||
import ChatPage from "../pages/chat";
|
||||
import { z } from "zod";
|
||||
|
||||
export const chatRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/chat",
|
||||
component: ChatPage,
|
||||
validateSearch: z.object({
|
||||
id: z.number().optional(),
|
||||
}),
|
||||
});
|
||||
12
src/routes/home.tsx
Normal file
12
src/routes/home.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { rootRoute } from "./root";
|
||||
import HomePage from "../pages/home";
|
||||
import { z } from "zod";
|
||||
export const homeRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/",
|
||||
component: HomePage,
|
||||
validateSearch: z.object({
|
||||
appId: z.number().optional(),
|
||||
}),
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user