Initial open-source release
This commit is contained in:
30
src/components/chat/ChatError.tsx
Normal file
30
src/components/chat/ChatError.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
import { XCircle, AlertTriangle } from "lucide-react"; // Assuming lucide-react is used
|
||||
|
||||
interface ChatErrorProps {
|
||||
error: string | null;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function ChatError({ error, onDismiss }: ChatErrorProps) {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex items-start text-red-600 bg-red-100 border border-red-500 rounded-md text-sm p-3 mx-4 mb-2 shadow-sm">
|
||||
<AlertTriangle
|
||||
className="h-5 w-5 mr-2 flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="flex-1">{error}</span>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="absolute top-1 right-1 p-1 rounded-full hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-red-400"
|
||||
aria-label="Dismiss error"
|
||||
>
|
||||
<XCircle className="h-4 w-4 text-red-500 hover:text-red-700" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
src/components/chat/ChatHeader.tsx
Normal file
88
src/components/chat/ChatHeader.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { PanelRightOpen, History, PlusCircle } from "lucide-react";
|
||||
import { PanelRightClose } from "lucide-react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { useLoadVersions } from "@/hooks/useLoadVersions";
|
||||
import { Button } from "../ui/button";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||
import { useChats } from "@/hooks/useChats";
|
||||
import { showError } from "@/lib/toast";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
isPreviewOpen: boolean;
|
||||
onTogglePreview: () => void;
|
||||
onVersionClick: () => void;
|
||||
}
|
||||
|
||||
export function ChatHeader({
|
||||
isPreviewOpen,
|
||||
onTogglePreview,
|
||||
onVersionClick,
|
||||
}: ChatHeaderProps) {
|
||||
const appId = useAtomValue(selectedAppIdAtom);
|
||||
const { versions, loading } = useLoadVersions(appId);
|
||||
const { navigate } = useRouter();
|
||||
const setSelectedChatId = useSetAtom(selectedChatIdAtom);
|
||||
const { refreshChats } = useChats(appId);
|
||||
|
||||
const handleNewChat = async () => {
|
||||
// Only create a new chat if an app is selected
|
||||
if (appId) {
|
||||
try {
|
||||
// Create a new chat with an empty title for now
|
||||
const chatId = await IpcClient.getInstance().createChat(appId);
|
||||
|
||||
// Navigate to the new chat
|
||||
setSelectedChatId(chatId);
|
||||
navigate({
|
||||
to: "/chat",
|
||||
search: { id: chatId },
|
||||
});
|
||||
|
||||
// Refresh the chat list
|
||||
await refreshChats();
|
||||
} catch (error) {
|
||||
// DO A TOAST
|
||||
showError(`Failed to create new chat: ${(error as any).toString()}`);
|
||||
}
|
||||
} else {
|
||||
// If no app is selected, navigate to home page
|
||||
navigate({ to: "/" });
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="@container flex items-center justify-between py-1.5">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
onClick={handleNewChat}
|
||||
variant="ghost"
|
||||
className="hidden @2xs:flex items-center justify-start gap-2 mx-2 py-3"
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
<span>New Chat</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onVersionClick}
|
||||
variant="ghost"
|
||||
className="hidden @6xs:flex cursor-pointer items-center gap-1 text-sm px-2 py-1 rounded-md"
|
||||
>
|
||||
<History size={16} />
|
||||
{loading ? "..." : `Version ${versions.length}`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onTogglePreview}
|
||||
className="cursor-pointer p-2 hover:bg-(--background-lightest) rounded-md"
|
||||
>
|
||||
{isPreviewOpen ? (
|
||||
<PanelRightClose size={20} />
|
||||
) : (
|
||||
<PanelRightOpen size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
src/components/chat/ChatInput.tsx
Normal file
139
src/components/chat/ChatInput.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { SendIcon, StopCircleIcon, X } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ModelPicker } from "@/components/ModelPicker";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { chatInputValueAtom } from "@/atoms/chatAtoms";
|
||||
import { useAtom } from "jotai";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
import { useChats } from "@/hooks/useChats";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { useLoadApp } from "@/hooks/useLoadApp";
|
||||
|
||||
interface ChatInputProps {
|
||||
chatId?: number;
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInput({ chatId, onSubmit }: ChatInputProps) {
|
||||
const [inputValue, setInputValue] = useAtom(chatInputValueAtom);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const { streamMessage, isStreaming, setIsStreaming, error, setError } =
|
||||
useStreamChat();
|
||||
const [selectedAppId] = useAtom(selectedAppIdAtom);
|
||||
const [showError, setShowError] = useState(true);
|
||||
|
||||
const adjustHeight = () => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = "0px";
|
||||
const scrollHeight = textarea.scrollHeight;
|
||||
console.log("scrollHeight", scrollHeight);
|
||||
textarea.style.height = `${scrollHeight + 4}px`;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
adjustHeight();
|
||||
}, [inputValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setShowError(true);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submitHandler();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!inputValue.trim() || isStreaming || !chatId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentInput = inputValue;
|
||||
setInputValue("");
|
||||
await streamMessage({ prompt: currentInput, chatId });
|
||||
};
|
||||
const submitHandler = onSubmit ? onSubmit : handleSubmit;
|
||||
|
||||
const handleCancel = () => {
|
||||
if (chatId) {
|
||||
IpcClient.getInstance().cancelChatStream(chatId);
|
||||
}
|
||||
setIsStreaming(false);
|
||||
};
|
||||
|
||||
const dismissError = () => {
|
||||
setShowError(false);
|
||||
};
|
||||
|
||||
if (!settings) {
|
||||
return null; // Or loading state
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && showError && (
|
||||
<div className="relative mt-2 bg-red-50 border border-red-200 rounded-md shadow-sm p-2">
|
||||
<button
|
||||
onClick={dismissError}
|
||||
className="absolute top-1 left-1 p-1 hover:bg-red-100 rounded"
|
||||
>
|
||||
<X size={14} className="text-red-500" />
|
||||
</button>
|
||||
<div className="px-6 py-1 text-sm">
|
||||
<div className="text-red-700 text-wrap">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col space-y-2 border border-border rounded-lg bg-(--background-lighter) shadow-sm">
|
||||
<div className="flex items-start space-x-2 ">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Ask Dyad to build..."
|
||||
className="flex-1 p-2 focus:outline-none overflow-y-auto min-h-[40px] max-h-[200px]"
|
||||
style={{ resize: "none" }}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
{isStreaming ? (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-2 py-2 mt-1 mr-2 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg"
|
||||
title="Cancel generation"
|
||||
>
|
||||
<StopCircleIcon size={20} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={submitHandler}
|
||||
disabled={!inputValue.trim()}
|
||||
className="px-2 py-2 mt-1 mr-2 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg disabled:opacity-50"
|
||||
>
|
||||
<SendIcon size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-2 pb-2">
|
||||
<ModelPicker
|
||||
selectedModel={settings.selectedModel}
|
||||
onModelSelect={(model) =>
|
||||
updateSettings({ selectedModel: model })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
80
src/components/chat/ChatMessage.tsx
Normal file
80
src/components/chat/ChatMessage.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { memo } from "react";
|
||||
import type { Message } from "ai";
|
||||
import { DyadMarkdownParser } from "./DyadMarkdownParser";
|
||||
import { motion } from "framer-motion";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
const ChatMessage = memo(
|
||||
({ message }: ChatMessageProps) => {
|
||||
return (
|
||||
<div
|
||||
className={`flex ${
|
||||
message.role === "assistant" ? "justify-start" : "justify-end"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`rounded-lg p-2 mt-2 ${
|
||||
message.role === "assistant"
|
||||
? "w-full max-w-3xl mx-auto"
|
||||
: "bg-(--sidebar-accent)"
|
||||
}`}
|
||||
>
|
||||
{message.role === "assistant" && !message.content ? (
|
||||
<div className="flex h-6 items-center space-x-2 p-2">
|
||||
<motion.div
|
||||
className="h-3 w-3 rounded-full bg-(--primary) dark:bg-blue-500"
|
||||
animate={{ y: [0, -12, 0] }}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration: 0.4,
|
||||
ease: "easeOut",
|
||||
repeatDelay: 1.2,
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className="h-3 w-3 rounded-full bg-(--primary) dark:bg-blue-500"
|
||||
animate={{ y: [0, -12, 0] }}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration: 0.4,
|
||||
ease: "easeOut",
|
||||
delay: 0.4,
|
||||
repeatDelay: 1.2,
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className="h-3 w-3 rounded-full bg-(--primary) dark:bg-blue-500"
|
||||
animate={{ y: [0, -12, 0] }}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration: 0.4,
|
||||
ease: "easeOut",
|
||||
delay: 0.8,
|
||||
repeatDelay: 1.2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="prose dark:prose-invert prose-headings:mb-2 prose-p:my-1 prose-pre:my-0 max-w-none"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<DyadMarkdownParser content={message.content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return prevProps.message.content === nextProps.message.content;
|
||||
}
|
||||
);
|
||||
|
||||
ChatMessage.displayName = "ChatMessage";
|
||||
|
||||
export default ChatMessage;
|
||||
89
src/components/chat/CodeHighlight.tsx
Normal file
89
src/components/chat/CodeHighlight.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { useEffect, useRef, memo, type ReactNode } from "react";
|
||||
import { isInlineCode, useShikiHighlighter } from "react-shiki";
|
||||
import github from "@shikijs/themes/github-light-default";
|
||||
import githubDark from "@shikijs/themes/github-dark-default";
|
||||
import type { Element as HastElement } from "hast";
|
||||
import { useTheme } from "../../contexts/ThemeContext";
|
||||
|
||||
interface CodeHighlightProps {
|
||||
className?: string | undefined;
|
||||
children?: ReactNode | undefined;
|
||||
node?: HastElement | undefined;
|
||||
}
|
||||
|
||||
export const CodeHighlight = memo(
|
||||
({ className, children, node, ...props }: CodeHighlightProps) => {
|
||||
const code = String(children).trim();
|
||||
const language = className?.match(/language-(\w+)/)?.[1];
|
||||
const isInline = node ? isInlineCode(node) : false;
|
||||
|
||||
// Get the current theme setting
|
||||
const { theme } = useTheme();
|
||||
|
||||
// State to track if dark mode is active
|
||||
const [isDarkMode, setIsDarkMode] = React.useState(false);
|
||||
|
||||
// Determine if dark mode is active when component mounts or theme changes
|
||||
useEffect(() => {
|
||||
const darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const updateTheme = () => {
|
||||
setIsDarkMode(
|
||||
theme === "dark" || (theme === "system" && darkModeQuery.matches)
|
||||
);
|
||||
};
|
||||
|
||||
updateTheme();
|
||||
darkModeQuery.addEventListener("change", updateTheme);
|
||||
|
||||
return () => {
|
||||
darkModeQuery.removeEventListener("change", updateTheme);
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
// Cache for the highlighted code
|
||||
const highlightedCodeCache = useRef<ReactNode | null>(null);
|
||||
|
||||
// Only update the highlighted code if the inputs change
|
||||
const highlightedCode = useShikiHighlighter(
|
||||
code,
|
||||
language,
|
||||
isDarkMode ? githubDark : github,
|
||||
{
|
||||
delay: 150,
|
||||
}
|
||||
);
|
||||
|
||||
// Update the cache whenever we get a new highlighted code
|
||||
useEffect(() => {
|
||||
if (highlightedCode) {
|
||||
highlightedCodeCache.current = highlightedCode;
|
||||
}
|
||||
}, [highlightedCode]);
|
||||
|
||||
// Use the cached version during transitions to prevent flickering
|
||||
const displayedCode = highlightedCode || highlightedCodeCache.current;
|
||||
return !isInline ? (
|
||||
<div
|
||||
className="shiki not-prose relative [&_pre]:overflow-auto
|
||||
[&_pre]:rounded-lg [&_pre]:px-6 [&_pre]:py-5"
|
||||
>
|
||||
{language ? (
|
||||
<span
|
||||
className="absolute right-3 top-2 text-xs tracking-tighter
|
||||
text-muted-foreground/85"
|
||||
>
|
||||
{language}
|
||||
</span>
|
||||
) : null}
|
||||
{displayedCode}
|
||||
</div>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return prevProps.children === nextProps.children;
|
||||
}
|
||||
);
|
||||
166
src/components/chat/DyadAddDependency.tsx
Normal file
166
src/components/chat/DyadAddDependency.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import type React from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import { IpcClient } from "../../ipc/ipc_client";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { chatMessagesAtom, selectedChatIdAtom } from "../../atoms/chatAtoms";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
import {
|
||||
Package,
|
||||
ChevronsUpDown,
|
||||
ChevronsDownUp,
|
||||
Loader,
|
||||
ExternalLink,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import { CodeHighlight } from "./CodeHighlight";
|
||||
|
||||
interface DyadAddDependencyProps {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
packages?: string;
|
||||
}
|
||||
|
||||
export const DyadAddDependency: React.FC<DyadAddDependencyProps> = ({
|
||||
children,
|
||||
node,
|
||||
}) => {
|
||||
// Extract package attribute from the node if available
|
||||
const packages = node?.properties?.packages?.split(" ") || "";
|
||||
console.log("packages", packages);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const selectedChatId = useAtomValue(selectedChatIdAtom);
|
||||
const [messages, setMessages] = useAtom(chatMessagesAtom);
|
||||
const { streamMessage, isStreaming } = useStreamChat();
|
||||
const [isContentVisible, setIsContentVisible] = useState(false);
|
||||
const hasChildren = !!children;
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!packages || !selectedChatId) return;
|
||||
|
||||
setIsInstalling(true);
|
||||
setError(null);
|
||||
try {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
|
||||
await ipcClient.addDependency({
|
||||
chatId: selectedChatId,
|
||||
packages,
|
||||
});
|
||||
|
||||
// Refresh the chat messages
|
||||
const chat = await IpcClient.getInstance().getChat(selectedChatId);
|
||||
setMessages(chat.messages);
|
||||
|
||||
await streamMessage({
|
||||
prompt: `I've installed ${packages.join(", ")}. Keep going.`,
|
||||
chatId: selectedChatId,
|
||||
});
|
||||
} catch (err) {
|
||||
setError("There was an error installing this package.");
|
||||
|
||||
const chat = await IpcClient.getInstance().getChat(selectedChatId);
|
||||
setMessages(chat.messages);
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-(--background-lightest) dark:bg-gray-900 hover:bg-(--background-lighter) rounded-lg px-4 py-3 border my-2 ${
|
||||
hasChildren ? "cursor-pointer" : ""
|
||||
} ${isInstalling ? "border-amber-500" : "border-border"}`}
|
||||
onClick={
|
||||
hasChildren ? () => setIsContentVisible(!isContentVisible) : undefined
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package size={18} className="text-gray-600 dark:text-gray-400" />
|
||||
{packages.length > 0 && (
|
||||
<div className="text-gray-800 dark:text-gray-200 font-semibold text-base">
|
||||
<div className="font-normal">
|
||||
Do you want to install these packages?
|
||||
</div>{" "}
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{packages.map((p: string) => (
|
||||
<span
|
||||
className="cursor-pointer text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
key={p}
|
||||
onClick={() => {
|
||||
IpcClient.getInstance().openExternalUrl(
|
||||
`https://www.npmjs.com/package/${p}`
|
||||
);
|
||||
}}
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isInstalling && (
|
||||
<div className="flex items-center text-amber-600 text-xs ml-2">
|
||||
<Loader size={14} className="mr-1 animate-spin" />
|
||||
<span>Installing...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasChildren && (
|
||||
<div className="flex items-center">
|
||||
{isContentVisible ? (
|
||||
<ChevronsDownUp
|
||||
size={20}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
/>
|
||||
) : (
|
||||
<ChevronsUpDown
|
||||
size={20}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{packages.length > 0 && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
Make sure these packages are what you want.{" "}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show content if it's visible and has children */}
|
||||
{isContentVisible && hasChildren && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs">
|
||||
<CodeHighlight className="language-shell">{children}</CodeHighlight>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Always show install button if there are no children */}
|
||||
{packages.length > 0 && !hasChildren && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
if (hasChildren) e.stopPropagation();
|
||||
handleInstall();
|
||||
}}
|
||||
disabled={isInstalling || isStreaming}
|
||||
size="default"
|
||||
variant="default"
|
||||
className="font-medium bg-primary/90 flex items-center gap-2 w-full max-w-sm py-4 mt-2 mb-2"
|
||||
>
|
||||
<Download size={16} />
|
||||
{isInstalling ? "Installing..." : "Install packages"}
|
||||
</Button>
|
||||
|
||||
{error && <div className="text-sm text-red-500 mt-2">{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
45
src/components/chat/DyadDelete.tsx
Normal file
45
src/components/chat/DyadDelete.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type React from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
interface DyadDeleteProps {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export const DyadDelete: React.FC<DyadDeleteProps> = ({
|
||||
children,
|
||||
node,
|
||||
path: pathProp,
|
||||
}) => {
|
||||
// Use props directly if provided, otherwise extract from node
|
||||
const path = pathProp || node?.properties?.path || "";
|
||||
|
||||
// Extract filename from path
|
||||
const fileName = path ? path.split("/").pop() : "";
|
||||
|
||||
return (
|
||||
<div className="bg-(--background-lightest) rounded-lg px-4 py-2 border border-red-500 my-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2 size={16} className="text-red-500" />
|
||||
{fileName && (
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
|
||||
{fileName}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-xs text-red-500 font-medium">Delete</div>
|
||||
</div>
|
||||
</div>
|
||||
{path && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">
|
||||
{path}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300 mt-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
285
src/components/chat/DyadMarkdownParser.tsx
Normal file
285
src/components/chat/DyadMarkdownParser.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import React, { useMemo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import { DyadWrite } from "./DyadWrite";
|
||||
import { DyadRename } from "./DyadRename";
|
||||
import { DyadDelete } from "./DyadDelete";
|
||||
import { DyadAddDependency } from "./DyadAddDependency";
|
||||
import { CodeHighlight } from "./CodeHighlight";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { isStreamingAtom } from "@/atoms/chatAtoms";
|
||||
import { CustomTagState } from "./stateTypes";
|
||||
|
||||
interface DyadMarkdownParserProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
type CustomTagInfo = {
|
||||
tag: string;
|
||||
attributes: Record<string, string>;
|
||||
content: string;
|
||||
fullMatch: string;
|
||||
inProgress?: boolean;
|
||||
};
|
||||
|
||||
type ContentPiece =
|
||||
| { type: "markdown"; content: string }
|
||||
| { type: "custom-tag"; tagInfo: CustomTagInfo };
|
||||
|
||||
/**
|
||||
* Custom component to parse markdown content with Dyad-specific tags
|
||||
*/
|
||||
export const DyadMarkdownParser: React.FC<DyadMarkdownParserProps> = ({
|
||||
content,
|
||||
}) => {
|
||||
const isStreaming = useAtomValue(isStreamingAtom);
|
||||
// Extract content pieces (markdown and custom tags)
|
||||
const contentPieces = useMemo(() => {
|
||||
return parseCustomTags(content);
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{contentPieces.map((piece, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{piece.type === "markdown"
|
||||
? piece.content && (
|
||||
<ReactMarkdown
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
components={{ code: CodeHighlight } as any}
|
||||
>
|
||||
{piece.content}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
: renderCustomTag(piece.tagInfo, { isStreaming })}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Pre-process content to handle unclosed custom tags
|
||||
* Adds closing tags at the end of the content for any unclosed custom tags
|
||||
* Assumes the opening tags are complete and valid
|
||||
* Returns the processed content and a map of in-progress tags
|
||||
*/
|
||||
function preprocessUnclosedTags(content: string): {
|
||||
processedContent: string;
|
||||
inProgressTags: Map<string, Set<number>>;
|
||||
} {
|
||||
const customTagNames = [
|
||||
"dyad-write",
|
||||
"dyad-rename",
|
||||
"dyad-delete",
|
||||
"dyad-add-dependency",
|
||||
];
|
||||
|
||||
let processedContent = content;
|
||||
// Map to track which tags are in progress and their positions
|
||||
const inProgressTags = new Map<string, Set<number>>();
|
||||
|
||||
// For each tag type, check if there are unclosed tags
|
||||
for (const tagName of customTagNames) {
|
||||
// Count opening and closing tags
|
||||
const openTagPattern = new RegExp(`<${tagName}(?:\\s[^>]*)?>`, "g");
|
||||
const closeTagPattern = new RegExp(`</${tagName}>`, "g");
|
||||
|
||||
// Track the positions of opening tags
|
||||
const openingMatches: RegExpExecArray[] = [];
|
||||
let match;
|
||||
|
||||
// Reset regex lastIndex to start from the beginning
|
||||
openTagPattern.lastIndex = 0;
|
||||
|
||||
while ((match = openTagPattern.exec(processedContent)) !== null) {
|
||||
openingMatches.push({ ...match });
|
||||
}
|
||||
|
||||
const openCount = openingMatches.length;
|
||||
const closeCount = (processedContent.match(closeTagPattern) || []).length;
|
||||
|
||||
// If we have more opening than closing tags
|
||||
const missingCloseTags = openCount - closeCount;
|
||||
if (missingCloseTags > 0) {
|
||||
// Add the required number of closing tags at the end
|
||||
processedContent += Array(missingCloseTags)
|
||||
.fill(`</${tagName}>`)
|
||||
.join("");
|
||||
|
||||
// Mark the last N tags as in progress where N is the number of missing closing tags
|
||||
const inProgressIndexes = new Set<number>();
|
||||
const startIndex = openCount - missingCloseTags;
|
||||
for (let i = startIndex; i < openCount; i++) {
|
||||
inProgressIndexes.add(openingMatches[i].index);
|
||||
}
|
||||
inProgressTags.set(tagName, inProgressIndexes);
|
||||
}
|
||||
}
|
||||
|
||||
return { processedContent, inProgressTags };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the content to extract custom tags and markdown sections into a unified array
|
||||
*/
|
||||
function parseCustomTags(content: string): ContentPiece[] {
|
||||
const { processedContent, inProgressTags } = preprocessUnclosedTags(content);
|
||||
|
||||
const customTagNames = [
|
||||
"dyad-write",
|
||||
"dyad-rename",
|
||||
"dyad-delete",
|
||||
"dyad-add-dependency",
|
||||
];
|
||||
|
||||
const tagPattern = new RegExp(
|
||||
`<(${customTagNames.join("|")})\\s*([^>]*)>(.*?)<\\/\\1>`,
|
||||
"gs"
|
||||
);
|
||||
|
||||
const contentPieces: ContentPiece[] = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
// Find all custom tags
|
||||
while ((match = tagPattern.exec(processedContent)) !== null) {
|
||||
const [fullMatch, tag, attributesStr, tagContent] = match;
|
||||
const startIndex = match.index;
|
||||
|
||||
// Add the markdown content before this tag
|
||||
if (startIndex > lastIndex) {
|
||||
contentPieces.push({
|
||||
type: "markdown",
|
||||
content: processedContent.substring(lastIndex, startIndex),
|
||||
});
|
||||
}
|
||||
|
||||
// Parse attributes
|
||||
const attributes: Record<string, string> = {};
|
||||
const attrPattern = /(\w+)="([^"]*)"/g;
|
||||
let attrMatch;
|
||||
while ((attrMatch = attrPattern.exec(attributesStr)) !== null) {
|
||||
attributes[attrMatch[1]] = attrMatch[2];
|
||||
}
|
||||
|
||||
// Check if this tag was marked as in progress
|
||||
const tagInProgressSet = inProgressTags.get(tag);
|
||||
const isInProgress = tagInProgressSet?.has(startIndex);
|
||||
|
||||
// Add the tag info
|
||||
contentPieces.push({
|
||||
type: "custom-tag",
|
||||
tagInfo: {
|
||||
tag,
|
||||
attributes,
|
||||
content: tagContent,
|
||||
fullMatch,
|
||||
inProgress: isInProgress || false,
|
||||
},
|
||||
});
|
||||
|
||||
lastIndex = startIndex + fullMatch.length;
|
||||
}
|
||||
|
||||
// Add the remaining markdown content
|
||||
if (lastIndex < processedContent.length) {
|
||||
contentPieces.push({
|
||||
type: "markdown",
|
||||
content: processedContent.substring(lastIndex),
|
||||
});
|
||||
}
|
||||
|
||||
return contentPieces;
|
||||
}
|
||||
|
||||
function getState({
|
||||
isStreaming,
|
||||
inProgress,
|
||||
}: {
|
||||
isStreaming?: boolean;
|
||||
inProgress?: boolean;
|
||||
}): CustomTagState {
|
||||
if (!inProgress) {
|
||||
return "finished";
|
||||
}
|
||||
return isStreaming ? "pending" : "aborted";
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a custom tag based on its type
|
||||
*/
|
||||
function renderCustomTag(
|
||||
tagInfo: CustomTagInfo,
|
||||
{ isStreaming }: { isStreaming: boolean }
|
||||
): React.ReactNode {
|
||||
const { tag, attributes, content, inProgress } = tagInfo;
|
||||
|
||||
switch (tag) {
|
||||
case "dyad-write":
|
||||
return (
|
||||
<DyadWrite
|
||||
node={{
|
||||
properties: {
|
||||
path: attributes.path || "",
|
||||
description: attributes.description || "",
|
||||
state: getState({ isStreaming, inProgress }),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DyadWrite>
|
||||
);
|
||||
|
||||
case "dyad-rename":
|
||||
return (
|
||||
<DyadRename
|
||||
node={{
|
||||
properties: {
|
||||
from: attributes.from || "",
|
||||
to: attributes.to || "",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DyadRename>
|
||||
);
|
||||
|
||||
case "dyad-delete":
|
||||
return (
|
||||
<DyadDelete
|
||||
node={{
|
||||
properties: {
|
||||
path: attributes.path || "",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DyadDelete>
|
||||
);
|
||||
|
||||
case "dyad-add-dependency":
|
||||
return (
|
||||
<DyadAddDependency
|
||||
node={{
|
||||
properties: {
|
||||
packages: attributes.packages || "",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DyadAddDependency>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract attribute values from className string
|
||||
*/
|
||||
function extractAttribute(className: string, attrName: string): string {
|
||||
const match = new RegExp(`${attrName}="([^"]*)"`, "g").exec(className);
|
||||
return match ? match[1] : "";
|
||||
}
|
||||
61
src/components/chat/DyadRename.tsx
Normal file
61
src/components/chat/DyadRename.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type React from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { FileEdit } from "lucide-react";
|
||||
|
||||
interface DyadRenameProps {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export const DyadRename: React.FC<DyadRenameProps> = ({
|
||||
children,
|
||||
node,
|
||||
from: fromProp,
|
||||
to: toProp,
|
||||
}) => {
|
||||
// Use props directly if provided, otherwise extract from node
|
||||
const from = fromProp || node?.properties?.from || "";
|
||||
const to = toProp || node?.properties?.to || "";
|
||||
|
||||
// Extract filenames from paths
|
||||
const fromFileName = from ? from.split("/").pop() : "";
|
||||
const toFileName = to ? to.split("/").pop() : "";
|
||||
|
||||
return (
|
||||
<div className="bg-(--background-lightest) rounded-lg px-4 py-2 border border-amber-500 my-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileEdit size={16} className="text-amber-500" />
|
||||
{(fromFileName || toFileName) && (
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
|
||||
{fromFileName && toFileName
|
||||
? `${fromFileName} → ${toFileName}`
|
||||
: fromFileName || toFileName}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-xs text-amber-500 font-medium">Rename</div>
|
||||
</div>
|
||||
</div>
|
||||
{(from || to) && (
|
||||
<div className="flex flex-col text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">
|
||||
{from && (
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">From:</span>{" "}
|
||||
{from}
|
||||
</div>
|
||||
)}
|
||||
{to && (
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">To:</span> {to}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300 mt-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
105
src/components/chat/DyadWrite.tsx
Normal file
105
src/components/chat/DyadWrite.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import type React from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Pencil,
|
||||
Loader,
|
||||
CircleX,
|
||||
} from "lucide-react";
|
||||
import { CodeHighlight } from "./CodeHighlight";
|
||||
import { CustomTagState } from "./stateTypes";
|
||||
|
||||
interface DyadWriteProps {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
path?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const DyadWrite: React.FC<DyadWriteProps> = ({
|
||||
children,
|
||||
node,
|
||||
path: pathProp,
|
||||
description: descriptionProp,
|
||||
}) => {
|
||||
const [isContentVisible, setIsContentVisible] = useState(false);
|
||||
|
||||
// Use props directly if provided, otherwise extract from node
|
||||
const path = pathProp || node?.properties?.path || "";
|
||||
const description = descriptionProp || node?.properties?.description || "";
|
||||
const state = node?.properties?.state as CustomTagState;
|
||||
const inProgress = state === "pending";
|
||||
const aborted = state === "aborted";
|
||||
|
||||
// Extract filename from path
|
||||
const fileName = path ? path.split("/").pop() : "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer ${
|
||||
inProgress
|
||||
? "border-amber-500"
|
||||
: aborted
|
||||
? "border-red-500"
|
||||
: "border-border"
|
||||
}`}
|
||||
onClick={() => setIsContentVisible(!isContentVisible)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil size={16} />
|
||||
{fileName && (
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
|
||||
{fileName}
|
||||
</span>
|
||||
)}
|
||||
{inProgress && (
|
||||
<div className="flex items-center text-amber-600 text-xs">
|
||||
<Loader size={14} className="mr-1 animate-spin" />
|
||||
<span>Writing...</span>
|
||||
</div>
|
||||
)}
|
||||
{aborted && (
|
||||
<div className="flex items-center text-red-600 text-xs">
|
||||
<CircleX size={14} className="mr-1" />
|
||||
<span>Did not finish</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{isContentVisible ? (
|
||||
<ChevronsDownUp
|
||||
size={20}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
/>
|
||||
) : (
|
||||
<ChevronsUpDown
|
||||
size={20}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{path && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">
|
||||
{path}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-medium">Summary: </span>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{isContentVisible && (
|
||||
<div className="text-xs">
|
||||
<CodeHighlight className="language-typescript">
|
||||
{children}
|
||||
</CodeHighlight>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
71
src/components/chat/MessagesList.tsx
Normal file
71
src/components/chat/MessagesList.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type React from "react";
|
||||
import type { Message } from "ai";
|
||||
import { forwardRef } from "react";
|
||||
import ChatMessage from "./ChatMessage";
|
||||
import { SetupBanner } from "../SetupBanner";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface MessagesListProps {
|
||||
messages: Message[];
|
||||
messagesEndRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
|
||||
function MessagesList({ messages, messagesEndRef }, ref) {
|
||||
const { streamMessage, isStreaming, error, setError } = useStreamChat();
|
||||
const { isAnyProviderSetup } = useSettings();
|
||||
const selectedChatId = useAtomValue(selectedChatIdAtom);
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4" ref={ref}>
|
||||
{messages.length > 0 ? (
|
||||
messages.map((message, index) => (
|
||||
<ChatMessage key={index} message={message} />
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full max-w-2xl mx-auto">
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
No messages yet
|
||||
</div>
|
||||
{!isAnyProviderSetup() && <SetupBanner />}
|
||||
</div>
|
||||
)}
|
||||
{messages.length > 0 && !isStreaming && (
|
||||
<div className="flex max-w-3xl mx-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!selectedChatId) {
|
||||
console.error("No chat selected");
|
||||
return;
|
||||
}
|
||||
// Find the last user message
|
||||
const lastUserMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === "user");
|
||||
if (!lastUserMessage) {
|
||||
console.error("No user message found");
|
||||
return;
|
||||
}
|
||||
streamMessage({
|
||||
prompt: lastUserMessage.content,
|
||||
chatId: selectedChatId,
|
||||
redo: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
134
src/components/chat/VersionPane.tsx
Normal file
134
src/components/chat/VersionPane.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { selectedAppIdAtom, selectedVersionIdAtom } from "@/atoms/appAtoms";
|
||||
import { useLoadVersions } from "@/hooks/useLoadVersions";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { RotateCcw, X } from "lucide-react";
|
||||
import type { Version } from "@/ipc/ipc_types";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface VersionPaneProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
|
||||
const appId = useAtomValue(selectedAppIdAtom);
|
||||
const { versions, loading, refreshVersions } = useLoadVersions(appId);
|
||||
const [selectedVersionId, setSelectedVersionId] = useAtom(
|
||||
selectedVersionIdAtom
|
||||
);
|
||||
useEffect(() => {
|
||||
// Refresh versions in case the user updated versions outside of the app
|
||||
// (e.g. manually using git).
|
||||
// Avoid loading state which causes brief flash of loading state.
|
||||
refreshVersions();
|
||||
if (!isVisible && selectedVersionId) {
|
||||
setSelectedVersionId(null);
|
||||
IpcClient.getInstance().checkoutVersion({
|
||||
appId: appId!,
|
||||
versionId: "main",
|
||||
});
|
||||
}
|
||||
}, [isVisible, refreshVersions]);
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full border-t border-2 border-border w-full">
|
||||
<div className="p-2 border-b border-border flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold pl-2">Version History</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-(--background-lightest) rounded-md "
|
||||
aria-label="Close version pane"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto h-[calc(100%-60px)]">
|
||||
{loading ? (
|
||||
<div className="p-4 ">Loading versions...</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div className="p-4 ">No versions available</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{versions.map((version: Version, index) => (
|
||||
<div
|
||||
key={version.oid}
|
||||
className={`px-4 py-2 hover:bg-(--background-lightest) cursor-pointer ${
|
||||
selectedVersionId === version.oid
|
||||
? "bg-(--background-lightest)"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
IpcClient.getInstance().checkoutVersion({
|
||||
appId: appId!,
|
||||
versionId: version.oid,
|
||||
});
|
||||
setSelectedVersionId(version.oid);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-xs">
|
||||
Version {versions.length - index}
|
||||
</span>
|
||||
<span className="text-xs opacity-90">
|
||||
{formatDistanceToNow(new Date(version.timestamp * 1000), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{version.message && (
|
||||
<p className="mt-1 text-sm">
|
||||
{version.message.startsWith(
|
||||
"Reverted all changes back to version "
|
||||
)
|
||||
? version.message.replace(
|
||||
/Reverted all changes back to version ([a-f0-9]+)/,
|
||||
(_, hash) => {
|
||||
const targetIndex = versions.findIndex(
|
||||
(v) => v.oid === hash
|
||||
);
|
||||
return targetIndex !== -1
|
||||
? `Reverted all changes back to version ${
|
||||
versions.length - targetIndex
|
||||
}`
|
||||
: version.message;
|
||||
}
|
||||
)
|
||||
: version.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedVersionId(null);
|
||||
await IpcClient.getInstance().revertVersion({
|
||||
appId: appId!,
|
||||
previousVersionId: version.oid,
|
||||
});
|
||||
refreshVersions();
|
||||
}}
|
||||
className={cn(
|
||||
"invisible mt-1 flex items-center gap-1 px-2 py-0.5 text-sm font-medium bg-(--primary) text-(--primary-foreground) hover:bg-background-lightest rounded-md transition-colors",
|
||||
selectedVersionId === version.oid && "visible"
|
||||
)}
|
||||
aria-label="Undo to latest version"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
<span>Undo</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
src/components/chat/monaco.ts
Normal file
189
src/components/chat/monaco.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { editor } from "monaco-editor";
|
||||
|
||||
import { loader } from "@monaco-editor/react";
|
||||
|
||||
import * as monaco from "monaco-editor";
|
||||
// @ts-ignore
|
||||
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
|
||||
// @ts-ignore
|
||||
import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
|
||||
// @ts-ignore
|
||||
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
|
||||
// @ts-ignore
|
||||
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
|
||||
// @ts-ignore
|
||||
import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
|
||||
|
||||
self.MonacoEnvironment = {
|
||||
getWorker(_, label) {
|
||||
if (label === "json") {
|
||||
return new jsonWorker();
|
||||
}
|
||||
if (label === "css" || label === "scss" || label === "less") {
|
||||
return new cssWorker();
|
||||
}
|
||||
if (label === "html" || label === "handlebars" || label === "razor") {
|
||||
return new htmlWorker();
|
||||
}
|
||||
if (label === "typescript" || label === "javascript") {
|
||||
return new tsWorker();
|
||||
}
|
||||
return new editorWorker();
|
||||
},
|
||||
};
|
||||
|
||||
loader.config({ monaco });
|
||||
|
||||
// loader.init().then(/* ... */);
|
||||
export const customLight: editor.IStandaloneThemeData = {
|
||||
base: "vs",
|
||||
inherit: false,
|
||||
rules: [
|
||||
{ token: "", foreground: "000000", background: "fffffe" },
|
||||
{ token: "invalid", foreground: "cd3131" },
|
||||
{ token: "emphasis", fontStyle: "italic" },
|
||||
{ token: "strong", fontStyle: "bold" },
|
||||
|
||||
{ token: "variable", foreground: "001188" },
|
||||
{ token: "variable.predefined", foreground: "4864AA" },
|
||||
{ token: "constant", foreground: "dd0000" },
|
||||
{ token: "comment", foreground: "008000" },
|
||||
{ token: "number", foreground: "098658" },
|
||||
{ token: "number.hex", foreground: "3030c0" },
|
||||
{ token: "regexp", foreground: "800000" },
|
||||
{ token: "annotation", foreground: "808080" },
|
||||
{ token: "type", foreground: "008080" },
|
||||
|
||||
{ token: "delimiter", foreground: "000000" },
|
||||
{ token: "delimiter.html", foreground: "383838" },
|
||||
{ token: "delimiter.xml", foreground: "0000FF" },
|
||||
|
||||
{ token: "tag", foreground: "800000" },
|
||||
{ token: "tag.id.pug", foreground: "4F76AC" },
|
||||
{ token: "tag.class.pug", foreground: "4F76AC" },
|
||||
{ token: "meta.scss", foreground: "800000" },
|
||||
{ token: "metatag", foreground: "e00000" },
|
||||
{ token: "metatag.content.html", foreground: "FF0000" },
|
||||
{ token: "metatag.html", foreground: "808080" },
|
||||
{ token: "metatag.xml", foreground: "808080" },
|
||||
{ token: "metatag.php", fontStyle: "bold" },
|
||||
|
||||
{ token: "key", foreground: "863B00" },
|
||||
{ token: "string.key.json", foreground: "A31515" },
|
||||
{ token: "string.value.json", foreground: "0451A5" },
|
||||
|
||||
{ token: "attribute.name", foreground: "FF0000" },
|
||||
{ token: "attribute.value", foreground: "0451A5" },
|
||||
{ token: "attribute.value.number", foreground: "098658" },
|
||||
{ token: "attribute.value.unit", foreground: "098658" },
|
||||
{ token: "attribute.value.html", foreground: "0000FF" },
|
||||
{ token: "attribute.value.xml", foreground: "0000FF" },
|
||||
|
||||
{ token: "string", foreground: "A31515" },
|
||||
{ token: "string.html", foreground: "0000FF" },
|
||||
{ token: "string.sql", foreground: "FF0000" },
|
||||
{ token: "string.yaml", foreground: "0451A5" },
|
||||
|
||||
{ token: "keyword", foreground: "0000FF" },
|
||||
{ token: "keyword.json", foreground: "0451A5" },
|
||||
{ token: "keyword.flow", foreground: "AF00DB" },
|
||||
{ token: "keyword.flow.scss", foreground: "0000FF" },
|
||||
|
||||
{ token: "operator.scss", foreground: "666666" },
|
||||
{ token: "operator.sql", foreground: "778899" },
|
||||
{ token: "operator.swift", foreground: "666666" },
|
||||
{ token: "predefined.sql", foreground: "C700C7" },
|
||||
],
|
||||
colors: {
|
||||
// surface
|
||||
"editor.background": "#f7f5ff",
|
||||
"minimap.background": "#f7f5ff",
|
||||
"editor.foreground": "#000000",
|
||||
"editor.inactiveSelectionBackground": "#E5EBF1",
|
||||
"editorIndentGuide.background1": "#D3D3D3",
|
||||
"editorIndentGuide.activeBackground1": "#939393",
|
||||
"editor.selectionHighlightBackground": "#ADD6FF4D",
|
||||
},
|
||||
};
|
||||
|
||||
editor.defineTheme("dyad-light", customLight);
|
||||
|
||||
export const customDark: editor.IStandaloneThemeData = {
|
||||
base: "vs-dark",
|
||||
inherit: false,
|
||||
rules: [
|
||||
{ token: "", foreground: "D4D4D4", background: "1E1E1E" },
|
||||
{ token: "invalid", foreground: "f44747" },
|
||||
{ token: "emphasis", fontStyle: "italic" },
|
||||
{ token: "strong", fontStyle: "bold" },
|
||||
|
||||
{ token: "variable", foreground: "74B0DF" },
|
||||
{ token: "variable.predefined", foreground: "4864AA" },
|
||||
{ token: "variable.parameter", foreground: "9CDCFE" },
|
||||
{ token: "constant", foreground: "569CD6" },
|
||||
{ token: "comment", foreground: "608B4E" },
|
||||
{ token: "number", foreground: "B5CEA8" },
|
||||
{ token: "number.hex", foreground: "5BB498" },
|
||||
{ token: "regexp", foreground: "B46695" },
|
||||
{ token: "annotation", foreground: "cc6666" },
|
||||
{ token: "type", foreground: "3DC9B0" },
|
||||
|
||||
{ token: "delimiter", foreground: "DCDCDC" },
|
||||
{ token: "delimiter.html", foreground: "808080" },
|
||||
{ token: "delimiter.xml", foreground: "808080" },
|
||||
|
||||
{ token: "tag", foreground: "569CD6" },
|
||||
{ token: "tag.id.pug", foreground: "4F76AC" },
|
||||
{ token: "tag.class.pug", foreground: "4F76AC" },
|
||||
{ token: "meta.scss", foreground: "A79873" },
|
||||
{ token: "meta.tag", foreground: "CE9178" },
|
||||
{ token: "metatag", foreground: "DD6A6F" },
|
||||
{ token: "metatag.content.html", foreground: "9CDCFE" },
|
||||
{ token: "metatag.html", foreground: "569CD6" },
|
||||
{ token: "metatag.xml", foreground: "569CD6" },
|
||||
{ token: "metatag.php", fontStyle: "bold" },
|
||||
|
||||
{ token: "key", foreground: "9CDCFE" },
|
||||
{ token: "string.key.json", foreground: "9CDCFE" },
|
||||
{ token: "string.value.json", foreground: "CE9178" },
|
||||
|
||||
{ token: "attribute.name", foreground: "9CDCFE" },
|
||||
{ token: "attribute.value", foreground: "CE9178" },
|
||||
{ token: "attribute.value.number.css", foreground: "B5CEA8" },
|
||||
{ token: "attribute.value.unit.css", foreground: "B5CEA8" },
|
||||
{ token: "attribute.value.hex.css", foreground: "D4D4D4" },
|
||||
|
||||
{ token: "string", foreground: "CE9178" },
|
||||
{ token: "string.sql", foreground: "FF0000" },
|
||||
|
||||
{ token: "keyword", foreground: "569CD6" },
|
||||
{ token: "keyword.flow", foreground: "C586C0" },
|
||||
{ token: "keyword.json", foreground: "CE9178" },
|
||||
{ token: "keyword.flow.scss", foreground: "569CD6" },
|
||||
|
||||
{ token: "operator.scss", foreground: "909090" },
|
||||
{ token: "operator.sql", foreground: "778899" },
|
||||
{ token: "operator.swift", foreground: "909090" },
|
||||
{ token: "predefined.sql", foreground: "FF00FF" },
|
||||
],
|
||||
colors: {
|
||||
// surface
|
||||
"editor.background": "#131316",
|
||||
"minimap.background": "#131316",
|
||||
"editor.foreground": "#D4D4D4",
|
||||
"editor.inactiveSelectionBackground": "#3A3D41",
|
||||
"editorIndentGuide.background1": "#404040",
|
||||
"editorIndentGuide.activeBackground1": "#707070",
|
||||
"editor.selectionHighlightBackground": "#ADD6FF26",
|
||||
},
|
||||
};
|
||||
|
||||
editor.defineTheme("dyad-dark", customDark);
|
||||
|
||||
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
|
||||
jsx: monaco.languages.typescript.JsxEmit.React, // Enable JSX
|
||||
});
|
||||
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
|
||||
// Too noisy because we don't have the full TS environment.
|
||||
noSemanticValidation: true,
|
||||
});
|
||||
1
src/components/chat/stateTypes.ts
Normal file
1
src/components/chat/stateTypes.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type CustomTagState = "pending" | "finished" | "aborted";
|
||||
30
src/components/chat/types.d.ts
vendored
Normal file
30
src/components/chat/types.d.ts
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Components as ReactMarkdownComponents } from "react-markdown";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// Extend the ReactMarkdown Components type to include our custom components
|
||||
declare module "react-markdown" {
|
||||
interface Components extends ReactMarkdownComponents {
|
||||
"dyad-write"?: (props: {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
path?: string;
|
||||
description?: string;
|
||||
}) => JSX.Element;
|
||||
"dyad-rename"?: (props: {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}) => JSX.Element;
|
||||
"dyad-delete"?: (props: {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
path?: string;
|
||||
}) => JSX.Element;
|
||||
"dyad-add-dependency"?: (props: {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
package?: string;
|
||||
}) => JSX.Element;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user