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

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