Initial open-source release

This commit is contained in:
Will Chen
2025-04-11 09:37:05 -07:00
commit 43f67e0739
208 changed files with 45476 additions and 0 deletions

43
src/App.css Normal file
View 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
View 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
View File

@@ -0,0 +1,77 @@
# Test Documentation
This directory contains unit tests for the Dyad application.
## Testing Setup
We use [Vitest](https://vitest.dev/) as our testing framework, which is designed to work well with Vite and modern JavaScript.
### Test Commands
Add these commands to your `package.json`:
```json
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui"
```
- `npm run test` - Run tests once
- `npm run test:watch` - Run tests in watch mode (rerun when files change)
- `npm run test:ui` - Run tests with UI reporter
## Mocking Guidelines
### Mocking fs module
When mocking the `node:fs` module, use a default export in the mock:
```typescript
vi.mock('node:fs', async () => {
return {
default: {
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
// Add other fs methods as needed
}
};
});
```
### Mocking isomorphic-git
When mocking isomorphic-git, provide a default export:
```typescript
vi.mock('isomorphic-git', () => ({
default: {
add: vi.fn().mockResolvedValue(undefined),
commit: vi.fn().mockResolvedValue(undefined),
// Add other git methods as needed
}
}));
```
### Testing IPC Handlers
When testing IPC handlers, mock the Electron IPC system:
```typescript
vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn(),
on: vi.fn(),
},
}));
```
## Adding New Tests
1. Create a new file with the `.test.ts` or `.spec.ts` extension
2. Import the functions you want to test
3. Mock any dependencies using `vi.mock()`
4. Write your test cases using `describe()` and `it()`
## Example
See `chat_stream_handlers.test.ts` for an example of testing IPC handlers with proper mocking.

View File

@@ -0,0 +1,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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
import { atom } from "jotai";
export const isPreviewOpenAtom = atom(true);
export const selectedFileAtom = atom<{
path: string;
} | null>(null);

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

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

View File

@@ -0,0 +1,84 @@
import React from "react";
interface ConfirmationDialogProps {
isOpen: boolean;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
confirmButtonClass?: string;
onConfirm: () => void;
onCancel: () => void;
}
export default function ConfirmationDialog({
isOpen,
title,
message,
confirmText = "Confirm",
cancelText = "Cancel",
confirmButtonClass = "bg-red-600 hover:bg-red-700 focus:ring-red-500",
onConfirm,
onCancel,
}: ConfirmationDialogProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-screen items-center justify-center p-4 text-center sm:p-0">
<div
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
onClick={onCancel}
/>
<div className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="bg-white dark:bg-gray-800 px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<svg
className="h-6 w-6 text-red-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900 dark:text-white">
{title}
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500 dark:text-gray-400">
{message}
</p>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-700 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
<button
type="button"
className={`inline-flex w-full justify-center rounded-md border border-transparent px-4 py-2 text-base font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm ${confirmButtonClass}`}
onClick={onConfirm}
>
{confirmText}
</button>
<button
type="button"
className="mt-3 inline-flex w-full justify-center rounded-md border border-gray-300 bg-white dark:bg-gray-600 dark:border-gray-500 dark:text-gray-200 px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:mt-0 sm:w-auto sm:text-sm"
onClick={onCancel}
>
{cancelText}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,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>
);
}

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

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

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

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

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

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

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

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

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

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

View 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] : "";
}

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

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

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

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

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

View File

@@ -0,0 +1 @@
export type CustomTagState = "pending" | "finished" | "aborted";

30
src/components/chat/types.d.ts vendored Normal file
View 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;
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,6 @@
export function useIsMobile() {
// Always return false to force desktop behavior
return false;
}

42
src/hooks/useChats.ts Normal file
View 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
View 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 };
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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