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,95 @@
import { useNavigate } from "@tanstack/react-router";
import { formatDistanceToNow } from "date-fns";
import { PlusCircle } from "lucide-react";
import { useAtom, useSetAtom } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useLoadApps } from "@/hooks/useLoadApps";
export function AppList({ show }: { show?: boolean }) {
const navigate = useNavigate();
const [selectedAppId, setSelectedAppId] = useAtom(selectedAppIdAtom);
const setSelectedChatId = useSetAtom(selectedChatIdAtom);
const { apps, loading, error } = useLoadApps();
if (!show) {
return null;
}
const handleAppClick = (id: number) => {
setSelectedAppId(id);
setSelectedChatId(null);
navigate({
to: "/",
search: { appId: id },
});
};
const handleNewApp = () => {
navigate({ to: "/" });
// We'll eventually need a create app workflow
};
return (
<SidebarGroup className="overflow-y-auto h-[calc(100vh-112px)]">
<SidebarGroupLabel>Your Apps</SidebarGroupLabel>
<SidebarGroupContent>
<div className="flex flex-col space-y-2">
<Button
onClick={handleNewApp}
variant="outline"
className="flex items-center justify-start gap-2 mx-2 py-2"
>
<PlusCircle size={16} />
<span>New App</span>
</Button>
{loading ? (
<div className="py-2 px-4 text-sm text-gray-500">
Loading apps...
</div>
) : error ? (
<div className="py-2 px-4 text-sm text-red-500">
Error loading apps
</div>
) : apps.length === 0 ? (
<div className="py-2 px-4 text-sm text-gray-500">No apps found</div>
) : (
<SidebarMenu className="space-y-1">
{apps.map((app) => (
<SidebarMenuItem key={app.id} className="mb-1">
<Button
variant="ghost"
onClick={() => handleAppClick(app.id)}
className={`justify-start w-full text-left py-3 hover:bg-sidebar-accent/80 ${
selectedAppId === app.id
? "bg-sidebar-accent text-sidebar-accent-foreground"
: ""
}`}
>
<div className="flex flex-col w-full">
<span className="truncate">{app.name}</span>
<span className="text-xs text-gray-500">
{formatDistanceToNow(new Date(app.createdAt), {
addSuffix: true,
})}
</span>
</div>
</Button>
</SidebarMenuItem>
))}
</SidebarMenu>
)}
</div>
</SidebarGroupContent>
</SidebarGroup>
);
}

140
src/components/ChatList.tsx Normal file
View File

@@ -0,0 +1,140 @@
import { useEffect } from "react";
import { useNavigate, useRouterState } from "@tanstack/react-router";
import type { ChatSummary } from "@/lib/schemas";
import { formatDistanceToNow } from "date-fns";
import { PlusCircle } from "lucide-react";
import { useAtom } from "jotai";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { IpcClient } from "@/ipc/ipc_client";
import { showError } from "@/lib/toast";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { useChats } from "@/hooks/useChats";
export function ChatList({ show }: { show?: boolean }) {
const navigate = useNavigate();
const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom);
const [selectedAppId, setSelectedAppId] = useAtom(selectedAppIdAtom);
const { chats, loading, refreshChats } = useChats(selectedAppId);
const routerState = useRouterState();
const isChatRoute = routerState.location.pathname === "/chat";
// Update selectedChatId when route changes
useEffect(() => {
if (isChatRoute) {
const id = routerState.location.search.id;
if (id) {
setSelectedChatId(id);
}
}
}, [isChatRoute, routerState.location.search, setSelectedChatId]);
if (!show) {
return;
}
const handleChatClick = ({
chatId,
appId,
}: {
chatId: number;
appId: number;
}) => {
setSelectedChatId(chatId);
setSelectedAppId(appId);
navigate({
to: "/chat",
search: { id: chatId },
});
};
const handleNewChat = async () => {
// Only create a new chat if an app is selected
if (selectedAppId) {
try {
// Create a new chat with an empty title for now
const chatId = await IpcClient.getInstance().createChat(selectedAppId);
// Navigate to the new chat
setSelectedChatId(chatId);
navigate({
to: "/chat",
search: { id: chatId },
});
// Refresh the chat list
await refreshChats();
} catch (error) {
// DO A TOAST
showError(`Failed to create new chat: ${(error as any).toString()}`);
}
} else {
// If no app is selected, navigate to home page
navigate({ to: "/" });
}
};
return (
<SidebarGroup className="overflow-y-auto h-[calc(100vh-112px)]">
<SidebarGroupLabel>Recent Chats</SidebarGroupLabel>
<SidebarGroupContent>
<div className="flex flex-col space-y-4">
<Button
onClick={handleNewChat}
variant="outline"
className="flex items-center justify-start gap-2 mx-2 py-3"
>
<PlusCircle size={16} />
<span>New Chat</span>
</Button>
{loading ? (
<div className="py-3 px-4 text-sm text-gray-500">
Loading chats...
</div>
) : chats.length === 0 ? (
<div className="py-3 px-4 text-sm text-gray-500">
No chats found
</div>
) : (
<SidebarMenu className="space-y-1">
{chats.map((chat) => (
<SidebarMenuItem key={chat.id} className="mb-1">
<Button
variant="ghost"
onClick={() =>
handleChatClick({ chatId: chat.id, appId: chat.appId })
}
className={`justify-start w-full text-left py-3 hover:bg-sidebar-accent/80 ${
selectedChatId === chat.id
? "bg-sidebar-accent text-sidebar-accent-foreground"
: ""
}`}
>
<div className="flex flex-col w-full">
<span className="truncate">
{chat.title || "New Chat"}
</span>
<span className="text-xs text-gray-500">
{formatDistanceToNow(new Date(chat.createdAt), {
addSuffix: true,
})}
</span>
</div>
</Button>
</SidebarMenuItem>
))}
</SidebarMenu>
)}
</div>
</SidebarGroupContent>
</SidebarGroup>
);
}

View File

@@ -0,0 +1,163 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { useAtom, useAtomValue } from "jotai";
import { chatMessagesAtom, chatStreamCountAtom } from "../atoms/chatAtoms";
import { IpcClient } from "@/ipc/ipc_client";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { ChatHeader } from "./chat/ChatHeader";
import { MessagesList } from "./chat/MessagesList";
import { ChatInput } from "./chat/ChatInput";
import { VersionPane } from "./chat/VersionPane";
import { ChatError } from "./chat/ChatError";
interface ChatPanelProps {
chatId?: number;
isPreviewOpen: boolean;
onTogglePreview: () => void;
}
export function ChatPanel({
chatId,
isPreviewOpen,
onTogglePreview,
}: ChatPanelProps) {
const appId = useAtomValue(selectedAppIdAtom);
const [messages, setMessages] = useAtom(chatMessagesAtom);
const [appName, setAppName] = useState<string>("Chat");
const [isVersionPaneOpen, setIsVersionPaneOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const streamCount = useAtomValue(chatStreamCountAtom);
// Reference to store the processed prompt so we don't submit it twice
const processedPromptRef = useRef<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement | null>(null);
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
// Scroll-related properties
const [isUserScrolling, setIsUserScrolling] = useState(false);
const userScrollTimeoutRef = useRef<number | null>(null);
const lastScrollTopRef = useRef<number>(0);
const scrollToBottom = (behavior: ScrollBehavior = "smooth") => {
messagesEndRef.current?.scrollIntoView({ behavior });
};
const handleScroll = () => {
if (!messagesContainerRef.current) return;
const container = messagesContainerRef.current;
const currentScrollTop = container.scrollTop;
if (currentScrollTop < lastScrollTopRef.current) {
setIsUserScrolling(true);
if (userScrollTimeoutRef.current) {
window.clearTimeout(userScrollTimeoutRef.current);
}
userScrollTimeoutRef.current = window.setTimeout(() => {
setIsUserScrolling(false);
}, 1000);
}
lastScrollTopRef.current = currentScrollTop;
};
useEffect(() => {
console.log("streamCount", streamCount);
scrollToBottom();
}, [streamCount]);
useEffect(() => {
const container = messagesContainerRef.current;
if (container) {
container.addEventListener("scroll", handleScroll, { passive: true });
}
return () => {
if (container) {
container.removeEventListener("scroll", handleScroll);
}
if (userScrollTimeoutRef.current) {
window.clearTimeout(userScrollTimeoutRef.current);
}
};
}, []);
useEffect(() => {
const fetchAppName = async () => {
if (!appId) return;
try {
const app = await IpcClient.getInstance().getApp(appId);
if (app?.name) {
setAppName(app.name);
}
} catch (error) {
console.error("Failed to fetch app name:", error);
}
};
fetchAppName();
}, [appId]);
const fetchChatMessages = useCallback(async () => {
if (!chatId) {
setMessages([]);
return;
}
const chat = await IpcClient.getInstance().getChat(chatId);
setMessages(chat.messages);
}, [chatId, setMessages]);
useEffect(() => {
fetchChatMessages();
}, [fetchChatMessages]);
// Auto-scroll effect when messages change
useEffect(() => {
if (
!isUserScrolling &&
messagesContainerRef.current &&
messages.length > 0
) {
const { scrollTop, clientHeight, scrollHeight } =
messagesContainerRef.current;
const threshold = 280;
const isNearBottom =
scrollHeight - (scrollTop + clientHeight) <= threshold;
if (isNearBottom) {
requestAnimationFrame(() => {
scrollToBottom("instant");
});
}
}
}, [messages, isUserScrolling]);
return (
<div className="flex flex-col h-full">
<ChatHeader
isPreviewOpen={isPreviewOpen}
onTogglePreview={onTogglePreview}
onVersionClick={() => setIsVersionPaneOpen(!isVersionPaneOpen)}
/>
<div className="flex flex-1 overflow-hidden">
{!isVersionPaneOpen && (
<div className="flex-1 flex flex-col min-w-0">
<MessagesList
messages={messages}
messagesEndRef={messagesEndRef}
ref={messagesContainerRef}
/>
<ChatError error={error} onDismiss={() => setError(null)} />
<ChatInput chatId={chatId} />
</div>
)}
<VersionPane
isVisible={isVersionPaneOpen}
onClose={() => setIsVersionPaneOpen(false)}
/>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,93 @@
import type { LargeLanguageModel, ModelProvider } from "@/lib/schemas";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useState } from "react";
import { MODEL_OPTIONS } from "@/constants/models";
interface ModelPickerProps {
selectedModel: LargeLanguageModel;
onModelSelect: (model: LargeLanguageModel) => void;
}
export function ModelPicker({
selectedModel,
onModelSelect,
}: ModelPickerProps) {
const [open, setOpen] = useState(false);
const modelDisplayName = MODEL_OPTIONS[selectedModel.provider].find(
(model) => model.name === selectedModel.name
)?.displayName;
// Flatten the model options into a single array with provider information
const allModels = Object.entries(MODEL_OPTIONS).flatMap(
([provider, models]) =>
models.map((model) => ({
...model,
provider: provider as ModelProvider,
}))
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex items-center gap-2 h-8"
>
<span>
<span className="text-xs text-muted-foreground">Model:</span>{" "}
{modelDisplayName}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-1" align="start">
<div className="grid gap-2">
{allModels.map((model) => (
<Tooltip key={model.name}>
<TooltipTrigger asChild>
<Button
variant={
selectedModel.name === model.name ? "secondary" : "ghost"
}
className="w-full justify-start font-normal"
onClick={() => {
onModelSelect({
name: model.name,
provider: model.provider,
});
setOpen(false);
}}
>
<div className="flex justify-between items-start w-full">
<span className="flex flex-col items-start">
<span>{model.displayName}</span>
<span className="text-xs text-muted-foreground">
{model.provider}
</span>
</span>
{model.tag && (
<span className="text-[10px] bg-primary/10 text-primary px-1.5 py-0.5 rounded-full font-medium">
{model.tag}
</span>
)}
</div>
</Button>
</TooltipTrigger>
<TooltipContent side="right">{model.description}</TooltipContent>
</Tooltip>
))}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,75 @@
import { PROVIDERS } from "@/constants/models";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { useNavigate } from "@tanstack/react-router";
import { providerSettingsRoute } from "@/routes/settings/providers/$provider";
import type { ModelProvider } from "@/lib/schemas";
import { useSettings } from "@/hooks/useSettings";
import { GiftIcon } from "lucide-react";
interface ProviderSettingsProps {
configuredProviders?: ModelProvider[];
}
export function ProviderSettingsGrid({
configuredProviders = [],
}: ProviderSettingsProps) {
const navigate = useNavigate();
const handleProviderClick = (provider: ModelProvider) => {
console.log("PROVIDER", provider);
navigate({
to: providerSettingsRoute.id,
params: { provider },
});
};
const { isProviderSetup } = useSettings();
return (
<div className="p-6">
<h2 className="text-2xl font-bold mb-6">AI Providers</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(PROVIDERS).map(([key, provider]) => {
const isConfigured = configuredProviders.includes(
key as ModelProvider
);
return (
<Card
key={key}
className="cursor-pointer transition-all hover:shadow-md border-border"
onClick={() => handleProviderClick(key as ModelProvider)}
>
<CardHeader className="p-4">
<CardTitle className="text-xl flex items-center justify-between">
{provider.displayName}
{isProviderSetup(key) ? (
<span className="ml-3 text-sm font-medium text-green-500 bg-green-50 dark:bg-green-900/30 border border-green-500/50 dark:border-green-500/50 px-2 py-1 rounded-full">
Ready
</span>
) : (
<span className="text-sm text-gray-500 bg-gray-50 dark:bg-gray-900 dark:text-gray-300 px-2 py-1 rounded-full">
Needs Setup
</span>
)}
</CardTitle>
<CardDescription>
{provider.hasFreeTier && (
<span className="text-blue-600 mt-2 dark:text-blue-400 text-sm font-medium bg-blue-100 dark:bg-blue-900/30 px-2 py-1 rounded-full inline-flex items-center">
<GiftIcon className="w-4 h-4 mr-1" />
Free tier available
</span>
)}
</CardDescription>
</CardHeader>
</Card>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { useNavigate } from "@tanstack/react-router";
import { ChevronRight, GiftIcon, Sparkles } from "lucide-react";
import { providerSettingsRoute } from "@/routes/settings/providers/$provider";
export function SetupBanner() {
const navigate = useNavigate();
const handleSetupClick = () => {
navigate({
to: providerSettingsRoute.id,
params: { provider: "google" },
});
};
return (
<div
className="w-full mb-8 p-4 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-xl shadow-sm cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors"
onClick={handleSetupClick}
>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="bg-blue-100 dark:bg-blue-800 p-2 rounded-full">
<Sparkles className="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h3 className="font-medium text-blue-800 dark:text-blue-300">
Setup your AI API access
</h3>
<p className="text-sm text-blue-600 dark:text-blue-400 flex items-center gap-1">
<GiftIcon className="w-3.5 h-3.5" />
Use Google Gemini for free
</p>
</div>
</div>
<ChevronRight className="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
</div>
);
}

View File

@@ -0,0 +1,174 @@
import { Home, Inbox, Settings } from "lucide-react";
import { Link, useRouterState } from "@tanstack/react-router";
import { useSidebar } from "@/components/ui/sidebar"; // import useSidebar hook
import { useEffect, useState, useRef } from "react";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { ChatList } from "./ChatList";
import { AppList } from "./AppList";
// Menu items.
const items = [
{
title: "Apps",
to: "/",
icon: Home,
},
{
title: "Chat",
to: "/chat",
icon: Inbox,
},
{
title: "Settings",
to: "/settings",
icon: Settings,
},
];
// Hover state types
type HoverState =
| "start-hover:app"
| "start-hover:chat"
| "clear-hover"
| "no-hover";
export function AppSidebar() {
const { state, toggleSidebar } = useSidebar(); // retrieve current sidebar state
const [hoverState, setHoverState] = useState<HoverState>("no-hover");
const expandedByHover = useRef(false);
useEffect(() => {
if (
(hoverState === "start-hover:app" || hoverState === "start-hover:chat") &&
state === "collapsed"
) {
expandedByHover.current = true;
toggleSidebar();
}
if (
hoverState === "clear-hover" &&
state === "expanded" &&
expandedByHover.current
) {
toggleSidebar();
expandedByHover.current = false;
setHoverState("no-hover");
}
}, [hoverState, toggleSidebar, state, setHoverState]);
const routerState = useRouterState();
const isAppRoute =
routerState.location.pathname === "/" ||
routerState.location.pathname.startsWith("/app-details");
const isChatRoute = routerState.location.pathname === "/chat";
let selectedItem: string | null = null;
if (hoverState === "start-hover:app") {
selectedItem = "Apps";
} else if (hoverState === "start-hover:chat") {
selectedItem = "Chat";
} else if (state === "expanded") {
if (isAppRoute) {
selectedItem = "Apps";
} else if (isChatRoute) {
selectedItem = "Chat";
}
}
return (
<Sidebar
collapsible="icon"
onMouseLeave={() => {
setHoverState("clear-hover");
}}
>
<SidebarContent className="overflow-hidden">
<div className="flex mt-8">
{/* Left Column: Menu items */}
<div className="">
<SidebarTrigger
onMouseEnter={() => {
setHoverState("clear-hover");
}}
/>
<AppIcons onHoverChange={setHoverState} />
</div>
{/* Right Column: Chat List Section */}
<div className="w-[240px]">
<AppList show={selectedItem === "Apps"} />
<ChatList show={selectedItem === "Chat"} />
</div>
</div>
</SidebarContent>
<SidebarRail />
</Sidebar>
);
}
function AppIcons({
onHoverChange,
}: {
onHoverChange: (state: HoverState) => void;
}) {
const routerState = useRouterState();
const pathname = routerState.location.pathname;
return (
// When collapsed: only show the main menu
<SidebarGroup className="pr-0">
{/* <SidebarGroupLabel>Dyad</SidebarGroupLabel> */}
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => {
const isActive =
(item.to === "/" && pathname === "/") ||
(item.to !== "/" && pathname.startsWith(item.to));
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
size="sm"
className="font-medium w-14"
>
<Link
to={item.to}
className={`flex flex-col items-center gap-1 h-14 mb-2 rounded-2xl ${
isActive ? "bg-sidebar-accent" : ""
}`}
onMouseEnter={() => {
if (item.title === "Apps") {
onHoverChange("start-hover:app");
} else if (item.title === "Chat") {
onHoverChange("start-hover:chat");
}
}}
>
<div className="flex flex-col items-center gap-1">
<item.icon className="h-5 w-5" />
<span className={"text-xs"}>{item.title}</span>
</div>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}

View File

@@ -0,0 +1,30 @@
import { XCircle, AlertTriangle } from "lucide-react"; // Assuming lucide-react is used
interface ChatErrorProps {
error: string | null;
onDismiss: () => void;
}
export function ChatError({ error, onDismiss }: ChatErrorProps) {
if (!error) {
return null;
}
return (
<div className="relative flex items-start text-red-600 bg-red-100 border border-red-500 rounded-md text-sm p-3 mx-4 mb-2 shadow-sm">
<AlertTriangle
className="h-5 w-5 mr-2 flex-shrink-0"
aria-hidden="true"
/>
<span className="flex-1">{error}</span>
<button
onClick={onDismiss}
className="absolute top-1 right-1 p-1 rounded-full hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-red-400"
aria-label="Dismiss error"
>
<XCircle className="h-4 w-4 text-red-500 hover:text-red-700" />
</button>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { PanelRightOpen, History, PlusCircle } from "lucide-react";
import { PanelRightClose } from "lucide-react";
import { useAtomValue, useSetAtom } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useLoadVersions } from "@/hooks/useLoadVersions";
import { Button } from "../ui/button";
import { IpcClient } from "@/ipc/ipc_client";
import { useRouter } from "@tanstack/react-router";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useChats } from "@/hooks/useChats";
import { showError } from "@/lib/toast";
interface ChatHeaderProps {
isPreviewOpen: boolean;
onTogglePreview: () => void;
onVersionClick: () => void;
}
export function ChatHeader({
isPreviewOpen,
onTogglePreview,
onVersionClick,
}: ChatHeaderProps) {
const appId = useAtomValue(selectedAppIdAtom);
const { versions, loading } = useLoadVersions(appId);
const { navigate } = useRouter();
const setSelectedChatId = useSetAtom(selectedChatIdAtom);
const { refreshChats } = useChats(appId);
const handleNewChat = async () => {
// Only create a new chat if an app is selected
if (appId) {
try {
// Create a new chat with an empty title for now
const chatId = await IpcClient.getInstance().createChat(appId);
// Navigate to the new chat
setSelectedChatId(chatId);
navigate({
to: "/chat",
search: { id: chatId },
});
// Refresh the chat list
await refreshChats();
} catch (error) {
// DO A TOAST
showError(`Failed to create new chat: ${(error as any).toString()}`);
}
} else {
// If no app is selected, navigate to home page
navigate({ to: "/" });
}
};
return (
<div className="@container flex items-center justify-between py-1.5">
<div className="flex items-center space-x-2">
<Button
onClick={handleNewChat}
variant="ghost"
className="hidden @2xs:flex items-center justify-start gap-2 mx-2 py-3"
>
<PlusCircle size={16} />
<span>New Chat</span>
</Button>
<Button
onClick={onVersionClick}
variant="ghost"
className="hidden @6xs:flex cursor-pointer items-center gap-1 text-sm px-2 py-1 rounded-md"
>
<History size={16} />
{loading ? "..." : `Version ${versions.length}`}
</Button>
</div>
<button
onClick={onTogglePreview}
className="cursor-pointer p-2 hover:bg-(--background-lightest) rounded-md"
>
{isPreviewOpen ? (
<PanelRightClose size={20} />
) : (
<PanelRightOpen size={20} />
)}
</button>
</div>
);
}

View File

@@ -0,0 +1,139 @@
import { SendIcon, StopCircleIcon, X } from "lucide-react";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { ModelPicker } from "@/components/ModelPicker";
import { useSettings } from "@/hooks/useSettings";
import { IpcClient } from "@/ipc/ipc_client";
import { chatInputValueAtom } from "@/atoms/chatAtoms";
import { useAtom } from "jotai";
import { useStreamChat } from "@/hooks/useStreamChat";
import { useChats } from "@/hooks/useChats";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useLoadApp } from "@/hooks/useLoadApp";
interface ChatInputProps {
chatId?: number;
onSubmit?: () => void;
}
export function ChatInput({ chatId, onSubmit }: ChatInputProps) {
const [inputValue, setInputValue] = useAtom(chatInputValueAtom);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { settings, updateSettings } = useSettings();
const { streamMessage, isStreaming, setIsStreaming, error, setError } =
useStreamChat();
const [selectedAppId] = useAtom(selectedAppIdAtom);
const [showError, setShowError] = useState(true);
const adjustHeight = () => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = "0px";
const scrollHeight = textarea.scrollHeight;
console.log("scrollHeight", scrollHeight);
textarea.style.height = `${scrollHeight + 4}px`;
}
};
useEffect(() => {
adjustHeight();
}, [inputValue]);
useEffect(() => {
if (error) {
setShowError(true);
}
}, [error]);
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submitHandler();
}
};
const handleSubmit = async () => {
if (!inputValue.trim() || isStreaming || !chatId) {
return;
}
const currentInput = inputValue;
setInputValue("");
await streamMessage({ prompt: currentInput, chatId });
};
const submitHandler = onSubmit ? onSubmit : handleSubmit;
const handleCancel = () => {
if (chatId) {
IpcClient.getInstance().cancelChatStream(chatId);
}
setIsStreaming(false);
};
const dismissError = () => {
setShowError(false);
};
if (!settings) {
return null; // Or loading state
}
return (
<>
{error && showError && (
<div className="relative mt-2 bg-red-50 border border-red-200 rounded-md shadow-sm p-2">
<button
onClick={dismissError}
className="absolute top-1 left-1 p-1 hover:bg-red-100 rounded"
>
<X size={14} className="text-red-500" />
</button>
<div className="px-6 py-1 text-sm">
<div className="text-red-700 text-wrap">{error}</div>
</div>
</div>
)}
<div className="p-4">
<div className="flex flex-col space-y-2 border border-border rounded-lg bg-(--background-lighter) shadow-sm">
<div className="flex items-start space-x-2 ">
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Ask Dyad to build..."
className="flex-1 p-2 focus:outline-none overflow-y-auto min-h-[40px] max-h-[200px]"
style={{ resize: "none" }}
disabled={isStreaming}
/>
{isStreaming ? (
<button
onClick={handleCancel}
className="px-2 py-2 mt-1 mr-2 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg"
title="Cancel generation"
>
<StopCircleIcon size={20} />
</button>
) : (
<button
onClick={submitHandler}
disabled={!inputValue.trim()}
className="px-2 py-2 mt-1 mr-2 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg disabled:opacity-50"
>
<SendIcon size={20} />
</button>
)}
</div>
<div className="px-2 pb-2">
<ModelPicker
selectedModel={settings.selectedModel}
onModelSelect={(model) =>
updateSettings({ selectedModel: model })
}
/>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,80 @@
import { memo } from "react";
import type { Message } from "ai";
import { DyadMarkdownParser } from "./DyadMarkdownParser";
import { motion } from "framer-motion";
import { useStreamChat } from "@/hooks/useStreamChat";
interface ChatMessageProps {
message: Message;
}
const ChatMessage = memo(
({ message }: ChatMessageProps) => {
return (
<div
className={`flex ${
message.role === "assistant" ? "justify-start" : "justify-end"
}`}
>
<div
className={`rounded-lg p-2 mt-2 ${
message.role === "assistant"
? "w-full max-w-3xl mx-auto"
: "bg-(--sidebar-accent)"
}`}
>
{message.role === "assistant" && !message.content ? (
<div className="flex h-6 items-center space-x-2 p-2">
<motion.div
className="h-3 w-3 rounded-full bg-(--primary) dark:bg-blue-500"
animate={{ y: [0, -12, 0] }}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration: 0.4,
ease: "easeOut",
repeatDelay: 1.2,
}}
/>
<motion.div
className="h-3 w-3 rounded-full bg-(--primary) dark:bg-blue-500"
animate={{ y: [0, -12, 0] }}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration: 0.4,
ease: "easeOut",
delay: 0.4,
repeatDelay: 1.2,
}}
/>
<motion.div
className="h-3 w-3 rounded-full bg-(--primary) dark:bg-blue-500"
animate={{ y: [0, -12, 0] }}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration: 0.4,
ease: "easeOut",
delay: 0.8,
repeatDelay: 1.2,
}}
/>
</div>
) : (
<div
className="prose dark:prose-invert prose-headings:mb-2 prose-p:my-1 prose-pre:my-0 max-w-none"
suppressHydrationWarning
>
<DyadMarkdownParser content={message.content} />
</div>
)}
</div>
</div>
);
},
(prevProps, nextProps) => {
return prevProps.message.content === nextProps.message.content;
}
);
ChatMessage.displayName = "ChatMessage";
export default ChatMessage;

View File

@@ -0,0 +1,89 @@
import React, { useEffect, useRef, memo, type ReactNode } from "react";
import { isInlineCode, useShikiHighlighter } from "react-shiki";
import github from "@shikijs/themes/github-light-default";
import githubDark from "@shikijs/themes/github-dark-default";
import type { Element as HastElement } from "hast";
import { useTheme } from "../../contexts/ThemeContext";
interface CodeHighlightProps {
className?: string | undefined;
children?: ReactNode | undefined;
node?: HastElement | undefined;
}
export const CodeHighlight = memo(
({ className, children, node, ...props }: CodeHighlightProps) => {
const code = String(children).trim();
const language = className?.match(/language-(\w+)/)?.[1];
const isInline = node ? isInlineCode(node) : false;
// Get the current theme setting
const { theme } = useTheme();
// State to track if dark mode is active
const [isDarkMode, setIsDarkMode] = React.useState(false);
// Determine if dark mode is active when component mounts or theme changes
useEffect(() => {
const darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)");
const updateTheme = () => {
setIsDarkMode(
theme === "dark" || (theme === "system" && darkModeQuery.matches)
);
};
updateTheme();
darkModeQuery.addEventListener("change", updateTheme);
return () => {
darkModeQuery.removeEventListener("change", updateTheme);
};
}, [theme]);
// Cache for the highlighted code
const highlightedCodeCache = useRef<ReactNode | null>(null);
// Only update the highlighted code if the inputs change
const highlightedCode = useShikiHighlighter(
code,
language,
isDarkMode ? githubDark : github,
{
delay: 150,
}
);
// Update the cache whenever we get a new highlighted code
useEffect(() => {
if (highlightedCode) {
highlightedCodeCache.current = highlightedCode;
}
}, [highlightedCode]);
// Use the cached version during transitions to prevent flickering
const displayedCode = highlightedCode || highlightedCodeCache.current;
return !isInline ? (
<div
className="shiki not-prose relative [&_pre]:overflow-auto
[&_pre]:rounded-lg [&_pre]:px-6 [&_pre]:py-5"
>
{language ? (
<span
className="absolute right-3 top-2 text-xs tracking-tighter
text-muted-foreground/85"
>
{language}
</span>
) : null}
{displayedCode}
</div>
) : (
<code className={className} {...props}>
{children}
</code>
);
},
(prevProps, nextProps) => {
return prevProps.children === nextProps.children;
}
);

View File

@@ -0,0 +1,166 @@
import type React from "react";
import type { ReactNode } from "react";
import { useState } from "react";
import { Button } from "../ui/button";
import { IpcClient } from "../../ipc/ipc_client";
import { useAtom, useAtomValue } from "jotai";
import { chatMessagesAtom, selectedChatIdAtom } from "../../atoms/chatAtoms";
import { useStreamChat } from "@/hooks/useStreamChat";
import {
Package,
ChevronsUpDown,
ChevronsDownUp,
Loader,
ExternalLink,
Download,
} from "lucide-react";
import { CodeHighlight } from "./CodeHighlight";
interface DyadAddDependencyProps {
children?: ReactNode;
node?: any;
packages?: string;
}
export const DyadAddDependency: React.FC<DyadAddDependencyProps> = ({
children,
node,
}) => {
// Extract package attribute from the node if available
const packages = node?.properties?.packages?.split(" ") || "";
console.log("packages", packages);
const [isInstalling, setIsInstalling] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectedChatId = useAtomValue(selectedChatIdAtom);
const [messages, setMessages] = useAtom(chatMessagesAtom);
const { streamMessage, isStreaming } = useStreamChat();
const [isContentVisible, setIsContentVisible] = useState(false);
const hasChildren = !!children;
const handleInstall = async () => {
if (!packages || !selectedChatId) return;
setIsInstalling(true);
setError(null);
try {
const ipcClient = IpcClient.getInstance();
await ipcClient.addDependency({
chatId: selectedChatId,
packages,
});
// Refresh the chat messages
const chat = await IpcClient.getInstance().getChat(selectedChatId);
setMessages(chat.messages);
await streamMessage({
prompt: `I've installed ${packages.join(", ")}. Keep going.`,
chatId: selectedChatId,
});
} catch (err) {
setError("There was an error installing this package.");
const chat = await IpcClient.getInstance().getChat(selectedChatId);
setMessages(chat.messages);
} finally {
setIsInstalling(false);
}
};
return (
<div
className={`bg-(--background-lightest) dark:bg-gray-900 hover:bg-(--background-lighter) rounded-lg px-4 py-3 border my-2 ${
hasChildren ? "cursor-pointer" : ""
} ${isInstalling ? "border-amber-500" : "border-border"}`}
onClick={
hasChildren ? () => setIsContentVisible(!isContentVisible) : undefined
}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Package size={18} className="text-gray-600 dark:text-gray-400" />
{packages.length > 0 && (
<div className="text-gray-800 dark:text-gray-200 font-semibold text-base">
<div className="font-normal">
Do you want to install these packages?
</div>{" "}
<div className="flex flex-wrap gap-2 mt-2">
{packages.map((p: string) => (
<span
className="cursor-pointer text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
key={p}
onClick={() => {
IpcClient.getInstance().openExternalUrl(
`https://www.npmjs.com/package/${p}`
);
}}
>
{p}
</span>
))}
</div>
</div>
)}
{isInstalling && (
<div className="flex items-center text-amber-600 text-xs ml-2">
<Loader size={14} className="mr-1 animate-spin" />
<span>Installing...</span>
</div>
)}
</div>
{hasChildren && (
<div className="flex items-center">
{isContentVisible ? (
<ChevronsDownUp
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
) : (
<ChevronsUpDown
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
)}
</div>
)}
</div>
{packages.length > 0 && (
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">
Make sure these packages are what you want.{" "}
</div>
)}
{/* Show content if it's visible and has children */}
{isContentVisible && hasChildren && (
<div className="mt-2">
<div className="text-xs">
<CodeHighlight className="language-shell">{children}</CodeHighlight>
</div>
</div>
)}
{/* Always show install button if there are no children */}
{packages.length > 0 && !hasChildren && (
<div className="mt-4 flex justify-center">
<Button
onClick={(e) => {
if (hasChildren) e.stopPropagation();
handleInstall();
}}
disabled={isInstalling || isStreaming}
size="default"
variant="default"
className="font-medium bg-primary/90 flex items-center gap-2 w-full max-w-sm py-4 mt-2 mb-2"
>
<Download size={16} />
{isInstalling ? "Installing..." : "Install packages"}
</Button>
{error && <div className="text-sm text-red-500 mt-2">{error}</div>}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,45 @@
import type React from "react";
import type { ReactNode } from "react";
import { Trash2 } from "lucide-react";
interface DyadDeleteProps {
children?: ReactNode;
node?: any;
path?: string;
}
export const DyadDelete: React.FC<DyadDeleteProps> = ({
children,
node,
path: pathProp,
}) => {
// Use props directly if provided, otherwise extract from node
const path = pathProp || node?.properties?.path || "";
// Extract filename from path
const fileName = path ? path.split("/").pop() : "";
return (
<div className="bg-(--background-lightest) rounded-lg px-4 py-2 border border-red-500 my-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Trash2 size={16} className="text-red-500" />
{fileName && (
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
{fileName}
</span>
)}
<div className="text-xs text-red-500 font-medium">Delete</div>
</div>
</div>
{path && (
<div className="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">
{path}
</div>
)}
<div className="text-sm text-gray-600 dark:text-gray-300 mt-2">
{children}
</div>
</div>
);
};

View File

@@ -0,0 +1,285 @@
import React, { useMemo } from "react";
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";
import { DyadWrite } from "./DyadWrite";
import { DyadRename } from "./DyadRename";
import { DyadDelete } from "./DyadDelete";
import { DyadAddDependency } from "./DyadAddDependency";
import { CodeHighlight } from "./CodeHighlight";
import { useAtomValue } from "jotai";
import { isStreamingAtom } from "@/atoms/chatAtoms";
import { CustomTagState } from "./stateTypes";
interface DyadMarkdownParserProps {
content: string;
}
type CustomTagInfo = {
tag: string;
attributes: Record<string, string>;
content: string;
fullMatch: string;
inProgress?: boolean;
};
type ContentPiece =
| { type: "markdown"; content: string }
| { type: "custom-tag"; tagInfo: CustomTagInfo };
/**
* Custom component to parse markdown content with Dyad-specific tags
*/
export const DyadMarkdownParser: React.FC<DyadMarkdownParserProps> = ({
content,
}) => {
const isStreaming = useAtomValue(isStreamingAtom);
// Extract content pieces (markdown and custom tags)
const contentPieces = useMemo(() => {
return parseCustomTags(content);
}, [content]);
return (
<>
{contentPieces.map((piece, index) => (
<React.Fragment key={index}>
{piece.type === "markdown"
? piece.content && (
<ReactMarkdown
rehypePlugins={[rehypeRaw]}
components={{ code: CodeHighlight } as any}
>
{piece.content}
</ReactMarkdown>
)
: renderCustomTag(piece.tagInfo, { isStreaming })}
</React.Fragment>
))}
</>
);
};
/**
* Pre-process content to handle unclosed custom tags
* Adds closing tags at the end of the content for any unclosed custom tags
* Assumes the opening tags are complete and valid
* Returns the processed content and a map of in-progress tags
*/
function preprocessUnclosedTags(content: string): {
processedContent: string;
inProgressTags: Map<string, Set<number>>;
} {
const customTagNames = [
"dyad-write",
"dyad-rename",
"dyad-delete",
"dyad-add-dependency",
];
let processedContent = content;
// Map to track which tags are in progress and their positions
const inProgressTags = new Map<string, Set<number>>();
// For each tag type, check if there are unclosed tags
for (const tagName of customTagNames) {
// Count opening and closing tags
const openTagPattern = new RegExp(`<${tagName}(?:\\s[^>]*)?>`, "g");
const closeTagPattern = new RegExp(`</${tagName}>`, "g");
// Track the positions of opening tags
const openingMatches: RegExpExecArray[] = [];
let match;
// Reset regex lastIndex to start from the beginning
openTagPattern.lastIndex = 0;
while ((match = openTagPattern.exec(processedContent)) !== null) {
openingMatches.push({ ...match });
}
const openCount = openingMatches.length;
const closeCount = (processedContent.match(closeTagPattern) || []).length;
// If we have more opening than closing tags
const missingCloseTags = openCount - closeCount;
if (missingCloseTags > 0) {
// Add the required number of closing tags at the end
processedContent += Array(missingCloseTags)
.fill(`</${tagName}>`)
.join("");
// Mark the last N tags as in progress where N is the number of missing closing tags
const inProgressIndexes = new Set<number>();
const startIndex = openCount - missingCloseTags;
for (let i = startIndex; i < openCount; i++) {
inProgressIndexes.add(openingMatches[i].index);
}
inProgressTags.set(tagName, inProgressIndexes);
}
}
return { processedContent, inProgressTags };
}
/**
* Parse the content to extract custom tags and markdown sections into a unified array
*/
function parseCustomTags(content: string): ContentPiece[] {
const { processedContent, inProgressTags } = preprocessUnclosedTags(content);
const customTagNames = [
"dyad-write",
"dyad-rename",
"dyad-delete",
"dyad-add-dependency",
];
const tagPattern = new RegExp(
`<(${customTagNames.join("|")})\\s*([^>]*)>(.*?)<\\/\\1>`,
"gs"
);
const contentPieces: ContentPiece[] = [];
let lastIndex = 0;
let match;
// Find all custom tags
while ((match = tagPattern.exec(processedContent)) !== null) {
const [fullMatch, tag, attributesStr, tagContent] = match;
const startIndex = match.index;
// Add the markdown content before this tag
if (startIndex > lastIndex) {
contentPieces.push({
type: "markdown",
content: processedContent.substring(lastIndex, startIndex),
});
}
// Parse attributes
const attributes: Record<string, string> = {};
const attrPattern = /(\w+)="([^"]*)"/g;
let attrMatch;
while ((attrMatch = attrPattern.exec(attributesStr)) !== null) {
attributes[attrMatch[1]] = attrMatch[2];
}
// Check if this tag was marked as in progress
const tagInProgressSet = inProgressTags.get(tag);
const isInProgress = tagInProgressSet?.has(startIndex);
// Add the tag info
contentPieces.push({
type: "custom-tag",
tagInfo: {
tag,
attributes,
content: tagContent,
fullMatch,
inProgress: isInProgress || false,
},
});
lastIndex = startIndex + fullMatch.length;
}
// Add the remaining markdown content
if (lastIndex < processedContent.length) {
contentPieces.push({
type: "markdown",
content: processedContent.substring(lastIndex),
});
}
return contentPieces;
}
function getState({
isStreaming,
inProgress,
}: {
isStreaming?: boolean;
inProgress?: boolean;
}): CustomTagState {
if (!inProgress) {
return "finished";
}
return isStreaming ? "pending" : "aborted";
}
/**
* Render a custom tag based on its type
*/
function renderCustomTag(
tagInfo: CustomTagInfo,
{ isStreaming }: { isStreaming: boolean }
): React.ReactNode {
const { tag, attributes, content, inProgress } = tagInfo;
switch (tag) {
case "dyad-write":
return (
<DyadWrite
node={{
properties: {
path: attributes.path || "",
description: attributes.description || "",
state: getState({ isStreaming, inProgress }),
},
}}
>
{content}
</DyadWrite>
);
case "dyad-rename":
return (
<DyadRename
node={{
properties: {
from: attributes.from || "",
to: attributes.to || "",
},
}}
>
{content}
</DyadRename>
);
case "dyad-delete":
return (
<DyadDelete
node={{
properties: {
path: attributes.path || "",
},
}}
>
{content}
</DyadDelete>
);
case "dyad-add-dependency":
return (
<DyadAddDependency
node={{
properties: {
packages: attributes.packages || "",
},
}}
>
{content}
</DyadAddDependency>
);
default:
return null;
}
}
/**
* Extract attribute values from className string
*/
function extractAttribute(className: string, attrName: string): string {
const match = new RegExp(`${attrName}="([^"]*)"`, "g").exec(className);
return match ? match[1] : "";
}

View File

@@ -0,0 +1,61 @@
import type React from "react";
import type { ReactNode } from "react";
import { FileEdit } from "lucide-react";
interface DyadRenameProps {
children?: ReactNode;
node?: any;
from?: string;
to?: string;
}
export const DyadRename: React.FC<DyadRenameProps> = ({
children,
node,
from: fromProp,
to: toProp,
}) => {
// Use props directly if provided, otherwise extract from node
const from = fromProp || node?.properties?.from || "";
const to = toProp || node?.properties?.to || "";
// Extract filenames from paths
const fromFileName = from ? from.split("/").pop() : "";
const toFileName = to ? to.split("/").pop() : "";
return (
<div className="bg-(--background-lightest) rounded-lg px-4 py-2 border border-amber-500 my-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileEdit size={16} className="text-amber-500" />
{(fromFileName || toFileName) && (
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
{fromFileName && toFileName
? `${fromFileName}${toFileName}`
: fromFileName || toFileName}
</span>
)}
<div className="text-xs text-amber-500 font-medium">Rename</div>
</div>
</div>
{(from || to) && (
<div className="flex flex-col text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">
{from && (
<div>
<span className="text-gray-500 dark:text-gray-400">From:</span>{" "}
{from}
</div>
)}
{to && (
<div>
<span className="text-gray-500 dark:text-gray-400">To:</span> {to}
</div>
)}
</div>
)}
<div className="text-sm text-gray-600 dark:text-gray-300 mt-2">
{children}
</div>
</div>
);
};

View File

@@ -0,0 +1,105 @@
import type React from "react";
import type { ReactNode } from "react";
import { useState } from "react";
import {
ChevronsDownUp,
ChevronsUpDown,
Pencil,
Loader,
CircleX,
} from "lucide-react";
import { CodeHighlight } from "./CodeHighlight";
import { CustomTagState } from "./stateTypes";
interface DyadWriteProps {
children?: ReactNode;
node?: any;
path?: string;
description?: string;
}
export const DyadWrite: React.FC<DyadWriteProps> = ({
children,
node,
path: pathProp,
description: descriptionProp,
}) => {
const [isContentVisible, setIsContentVisible] = useState(false);
// Use props directly if provided, otherwise extract from node
const path = pathProp || node?.properties?.path || "";
const description = descriptionProp || node?.properties?.description || "";
const state = node?.properties?.state as CustomTagState;
const inProgress = state === "pending";
const aborted = state === "aborted";
// Extract filename from path
const fileName = path ? path.split("/").pop() : "";
return (
<div
className={`bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer ${
inProgress
? "border-amber-500"
: aborted
? "border-red-500"
: "border-border"
}`}
onClick={() => setIsContentVisible(!isContentVisible)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Pencil size={16} />
{fileName && (
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
{fileName}
</span>
)}
{inProgress && (
<div className="flex items-center text-amber-600 text-xs">
<Loader size={14} className="mr-1 animate-spin" />
<span>Writing...</span>
</div>
)}
{aborted && (
<div className="flex items-center text-red-600 text-xs">
<CircleX size={14} className="mr-1" />
<span>Did not finish</span>
</div>
)}
</div>
<div className="flex items-center">
{isContentVisible ? (
<ChevronsDownUp
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
) : (
<ChevronsUpDown
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
)}
</div>
</div>
{path && (
<div className="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">
{path}
</div>
)}
{description && (
<div className="text-sm text-gray-600 dark:text-gray-300">
<span className="font-medium">Summary: </span>
{description}
</div>
)}
{isContentVisible && (
<div className="text-xs">
<CodeHighlight className="language-typescript">
{children}
</CodeHighlight>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,71 @@
import type React from "react";
import type { Message } from "ai";
import { forwardRef } from "react";
import ChatMessage from "./ChatMessage";
import { SetupBanner } from "../SetupBanner";
import { useSettings } from "@/hooks/useSettings";
import { useStreamChat } from "@/hooks/useStreamChat";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useAtom, useAtomValue } from "jotai";
import { RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
interface MessagesListProps {
messages: Message[];
messagesEndRef: React.RefObject<HTMLDivElement | null>;
}
export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
function MessagesList({ messages, messagesEndRef }, ref) {
const { streamMessage, isStreaming, error, setError } = useStreamChat();
const { isAnyProviderSetup } = useSettings();
const selectedChatId = useAtomValue(selectedChatIdAtom);
return (
<div className="flex-1 overflow-y-auto p-4" ref={ref}>
{messages.length > 0 ? (
messages.map((message, index) => (
<ChatMessage key={index} message={message} />
))
) : (
<div className="flex flex-col items-center justify-center h-full max-w-2xl mx-auto">
<div className="flex items-center justify-center h-full text-gray-500">
No messages yet
</div>
{!isAnyProviderSetup() && <SetupBanner />}
</div>
)}
{messages.length > 0 && !isStreaming && (
<div className="flex max-w-3xl mx-auto">
<Button
variant="ghost"
size="sm"
onClick={() => {
if (!selectedChatId) {
console.error("No chat selected");
return;
}
// Find the last user message
const lastUserMessage = [...messages]
.reverse()
.find((message) => message.role === "user");
if (!lastUserMessage) {
console.error("No user message found");
return;
}
streamMessage({
prompt: lastUserMessage.content,
chatId: selectedChatId,
redo: true,
});
}}
>
<RefreshCw size={16} />
Retry
</Button>
</div>
)}
<div ref={messagesEndRef} />
</div>
);
}
);

View File

@@ -0,0 +1,134 @@
import { useAtom, useAtomValue } from "jotai";
import { selectedAppIdAtom, selectedVersionIdAtom } from "@/atoms/appAtoms";
import { useLoadVersions } from "@/hooks/useLoadVersions";
import { formatDistanceToNow } from "date-fns";
import { RotateCcw, X } from "lucide-react";
import type { Version } from "@/ipc/ipc_types";
import { IpcClient } from "@/ipc/ipc_client";
import { cn } from "@/lib/utils";
import { useEffect } from "react";
interface VersionPaneProps {
isVisible: boolean;
onClose: () => void;
}
export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
const appId = useAtomValue(selectedAppIdAtom);
const { versions, loading, refreshVersions } = useLoadVersions(appId);
const [selectedVersionId, setSelectedVersionId] = useAtom(
selectedVersionIdAtom
);
useEffect(() => {
// Refresh versions in case the user updated versions outside of the app
// (e.g. manually using git).
// Avoid loading state which causes brief flash of loading state.
refreshVersions();
if (!isVisible && selectedVersionId) {
setSelectedVersionId(null);
IpcClient.getInstance().checkoutVersion({
appId: appId!,
versionId: "main",
});
}
}, [isVisible, refreshVersions]);
if (!isVisible) {
return null;
}
return (
<div className="h-full border-t border-2 border-border w-full">
<div className="p-2 border-b border-border flex items-center justify-between">
<h2 className="text-base font-semibold pl-2">Version History</h2>
<button
onClick={onClose}
className="p-1 hover:bg-(--background-lightest) rounded-md "
aria-label="Close version pane"
>
<X size={20} />
</button>
</div>
<div className="overflow-y-auto h-[calc(100%-60px)]">
{loading ? (
<div className="p-4 ">Loading versions...</div>
) : versions.length === 0 ? (
<div className="p-4 ">No versions available</div>
) : (
<div className="divide-y divide-border">
{versions.map((version: Version, index) => (
<div
key={version.oid}
className={`px-4 py-2 hover:bg-(--background-lightest) cursor-pointer ${
selectedVersionId === version.oid
? "bg-(--background-lightest)"
: ""
}`}
onClick={() => {
IpcClient.getInstance().checkoutVersion({
appId: appId!,
versionId: version.oid,
});
setSelectedVersionId(version.oid);
}}
>
<div className="flex items-center justify-between">
<span className="font-medium text-xs">
Version {versions.length - index}
</span>
<span className="text-xs opacity-90">
{formatDistanceToNow(new Date(version.timestamp * 1000), {
addSuffix: true,
})}
</span>
</div>
<div className="flex items-center justify-between gap-2">
{version.message && (
<p className="mt-1 text-sm">
{version.message.startsWith(
"Reverted all changes back to version "
)
? version.message.replace(
/Reverted all changes back to version ([a-f0-9]+)/,
(_, hash) => {
const targetIndex = versions.findIndex(
(v) => v.oid === hash
);
return targetIndex !== -1
? `Reverted all changes back to version ${
versions.length - targetIndex
}`
: version.message;
}
)
: version.message}
</p>
)}
<button
onClick={async (e) => {
e.stopPropagation();
setSelectedVersionId(null);
await IpcClient.getInstance().revertVersion({
appId: appId!,
previousVersionId: version.oid,
});
refreshVersions();
}}
className={cn(
"invisible mt-1 flex items-center gap-1 px-2 py-0.5 text-sm font-medium bg-(--primary) text-(--primary-foreground) hover:bg-background-lightest rounded-md transition-colors",
selectedVersionId === version.oid && "visible"
)}
aria-label="Undo to latest version"
>
<RotateCcw size={12} />
<span>Undo</span>
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,189 @@
import { editor } from "monaco-editor";
import { loader } from "@monaco-editor/react";
import * as monaco from "monaco-editor";
// @ts-ignore
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
// @ts-ignore
import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
// @ts-ignore
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
// @ts-ignore
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
// @ts-ignore
import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
self.MonacoEnvironment = {
getWorker(_, label) {
if (label === "json") {
return new jsonWorker();
}
if (label === "css" || label === "scss" || label === "less") {
return new cssWorker();
}
if (label === "html" || label === "handlebars" || label === "razor") {
return new htmlWorker();
}
if (label === "typescript" || label === "javascript") {
return new tsWorker();
}
return new editorWorker();
},
};
loader.config({ monaco });
// loader.init().then(/* ... */);
export const customLight: editor.IStandaloneThemeData = {
base: "vs",
inherit: false,
rules: [
{ token: "", foreground: "000000", background: "fffffe" },
{ token: "invalid", foreground: "cd3131" },
{ token: "emphasis", fontStyle: "italic" },
{ token: "strong", fontStyle: "bold" },
{ token: "variable", foreground: "001188" },
{ token: "variable.predefined", foreground: "4864AA" },
{ token: "constant", foreground: "dd0000" },
{ token: "comment", foreground: "008000" },
{ token: "number", foreground: "098658" },
{ token: "number.hex", foreground: "3030c0" },
{ token: "regexp", foreground: "800000" },
{ token: "annotation", foreground: "808080" },
{ token: "type", foreground: "008080" },
{ token: "delimiter", foreground: "000000" },
{ token: "delimiter.html", foreground: "383838" },
{ token: "delimiter.xml", foreground: "0000FF" },
{ token: "tag", foreground: "800000" },
{ token: "tag.id.pug", foreground: "4F76AC" },
{ token: "tag.class.pug", foreground: "4F76AC" },
{ token: "meta.scss", foreground: "800000" },
{ token: "metatag", foreground: "e00000" },
{ token: "metatag.content.html", foreground: "FF0000" },
{ token: "metatag.html", foreground: "808080" },
{ token: "metatag.xml", foreground: "808080" },
{ token: "metatag.php", fontStyle: "bold" },
{ token: "key", foreground: "863B00" },
{ token: "string.key.json", foreground: "A31515" },
{ token: "string.value.json", foreground: "0451A5" },
{ token: "attribute.name", foreground: "FF0000" },
{ token: "attribute.value", foreground: "0451A5" },
{ token: "attribute.value.number", foreground: "098658" },
{ token: "attribute.value.unit", foreground: "098658" },
{ token: "attribute.value.html", foreground: "0000FF" },
{ token: "attribute.value.xml", foreground: "0000FF" },
{ token: "string", foreground: "A31515" },
{ token: "string.html", foreground: "0000FF" },
{ token: "string.sql", foreground: "FF0000" },
{ token: "string.yaml", foreground: "0451A5" },
{ token: "keyword", foreground: "0000FF" },
{ token: "keyword.json", foreground: "0451A5" },
{ token: "keyword.flow", foreground: "AF00DB" },
{ token: "keyword.flow.scss", foreground: "0000FF" },
{ token: "operator.scss", foreground: "666666" },
{ token: "operator.sql", foreground: "778899" },
{ token: "operator.swift", foreground: "666666" },
{ token: "predefined.sql", foreground: "C700C7" },
],
colors: {
// surface
"editor.background": "#f7f5ff",
"minimap.background": "#f7f5ff",
"editor.foreground": "#000000",
"editor.inactiveSelectionBackground": "#E5EBF1",
"editorIndentGuide.background1": "#D3D3D3",
"editorIndentGuide.activeBackground1": "#939393",
"editor.selectionHighlightBackground": "#ADD6FF4D",
},
};
editor.defineTheme("dyad-light", customLight);
export const customDark: editor.IStandaloneThemeData = {
base: "vs-dark",
inherit: false,
rules: [
{ token: "", foreground: "D4D4D4", background: "1E1E1E" },
{ token: "invalid", foreground: "f44747" },
{ token: "emphasis", fontStyle: "italic" },
{ token: "strong", fontStyle: "bold" },
{ token: "variable", foreground: "74B0DF" },
{ token: "variable.predefined", foreground: "4864AA" },
{ token: "variable.parameter", foreground: "9CDCFE" },
{ token: "constant", foreground: "569CD6" },
{ token: "comment", foreground: "608B4E" },
{ token: "number", foreground: "B5CEA8" },
{ token: "number.hex", foreground: "5BB498" },
{ token: "regexp", foreground: "B46695" },
{ token: "annotation", foreground: "cc6666" },
{ token: "type", foreground: "3DC9B0" },
{ token: "delimiter", foreground: "DCDCDC" },
{ token: "delimiter.html", foreground: "808080" },
{ token: "delimiter.xml", foreground: "808080" },
{ token: "tag", foreground: "569CD6" },
{ token: "tag.id.pug", foreground: "4F76AC" },
{ token: "tag.class.pug", foreground: "4F76AC" },
{ token: "meta.scss", foreground: "A79873" },
{ token: "meta.tag", foreground: "CE9178" },
{ token: "metatag", foreground: "DD6A6F" },
{ token: "metatag.content.html", foreground: "9CDCFE" },
{ token: "metatag.html", foreground: "569CD6" },
{ token: "metatag.xml", foreground: "569CD6" },
{ token: "metatag.php", fontStyle: "bold" },
{ token: "key", foreground: "9CDCFE" },
{ token: "string.key.json", foreground: "9CDCFE" },
{ token: "string.value.json", foreground: "CE9178" },
{ token: "attribute.name", foreground: "9CDCFE" },
{ token: "attribute.value", foreground: "CE9178" },
{ token: "attribute.value.number.css", foreground: "B5CEA8" },
{ token: "attribute.value.unit.css", foreground: "B5CEA8" },
{ token: "attribute.value.hex.css", foreground: "D4D4D4" },
{ token: "string", foreground: "CE9178" },
{ token: "string.sql", foreground: "FF0000" },
{ token: "keyword", foreground: "569CD6" },
{ token: "keyword.flow", foreground: "C586C0" },
{ token: "keyword.json", foreground: "CE9178" },
{ token: "keyword.flow.scss", foreground: "569CD6" },
{ token: "operator.scss", foreground: "909090" },
{ token: "operator.sql", foreground: "778899" },
{ token: "operator.swift", foreground: "909090" },
{ token: "predefined.sql", foreground: "FF00FF" },
],
colors: {
// surface
"editor.background": "#131316",
"minimap.background": "#131316",
"editor.foreground": "#D4D4D4",
"editor.inactiveSelectionBackground": "#3A3D41",
"editorIndentGuide.background1": "#404040",
"editorIndentGuide.activeBackground1": "#707070",
"editor.selectionHighlightBackground": "#ADD6FF26",
},
};
editor.defineTheme("dyad-dark", customDark);
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
jsx: monaco.languages.typescript.JsxEmit.React, // Enable JSX
});
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
// Too noisy because we don't have the full TS environment.
noSemanticValidation: true,
});

View File

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

30
src/components/chat/types.d.ts vendored Normal file
View File

@@ -0,0 +1,30 @@
import type { Components as ReactMarkdownComponents } from "react-markdown";
import type { ReactNode } from "react";
// Extend the ReactMarkdown Components type to include our custom components
declare module "react-markdown" {
interface Components extends ReactMarkdownComponents {
"dyad-write"?: (props: {
children?: ReactNode;
node?: any;
path?: string;
description?: string;
}) => JSX.Element;
"dyad-rename"?: (props: {
children?: ReactNode;
node?: any;
from?: string;
to?: string;
}) => JSX.Element;
"dyad-delete"?: (props: {
children?: ReactNode;
node?: any;
path?: string;
}) => JSX.Element;
"dyad-add-dependency"?: (props: {
children?: ReactNode;
node?: any;
package?: string;
}) => JSX.Element;
}
}

View File

@@ -0,0 +1,79 @@
import { useState } from "react";
import { FileEditor } from "./FileEditor";
import { FileTree } from "./FileTree";
import { RefreshCw } from "lucide-react";
import { useLoadApp } from "@/hooks/useLoadApp";
import { useAtomValue } from "jotai";
import { selectedFileAtom } from "@/atoms/viewAtoms";
interface App {
id?: number;
files?: string[];
}
export interface CodeViewProps {
loading: boolean;
error: Error | null;
app: App | null;
}
// Code view component that displays app files or status messages
export const CodeView = ({ loading, error, app }: CodeViewProps) => {
const selectedFile = useAtomValue(selectedFileAtom);
const { refreshApp } = useLoadApp(app?.id ?? null);
if (loading) {
return <div className="text-center py-4">Loading files...</div>;
}
if (error) {
return (
<div className="text-center py-4 text-red-500">
Error loading files: {error.message}
</div>
);
}
if (!app) {
return (
<div className="text-center py-4 text-gray-500">No app selected</div>
);
}
if (app.files && app.files.length > 0) {
return (
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="flex items-center p-2 border-b space-x-2">
<button
onClick={refreshApp}
className="p-1 rounded hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={loading || !app.id}
title="Refresh Files"
>
<RefreshCw size={16} />
</button>
<div className="text-sm text-gray-500">{app.files.length} files</div>
</div>
{/* Content */}
<div className="flex flex-1 overflow-hidden">
<div className="w-1/3 overflow-auto border-r">
<FileTree files={app.files} />
</div>
<div className="w-2/3">
{selectedFile ? (
<FileEditor appId={app.id ?? null} filePath={selectedFile.path} />
) : (
<div className="text-center py-4 text-gray-500">
Select a file to view
</div>
)}
</div>
</div>
</div>
);
}
return <div className="text-center py-4 text-gray-500">No files found</div>;
};

View File

@@ -0,0 +1,14 @@
import { appOutputAtom } from "@/atoms/appAtoms";
import { useAtomValue } from "jotai";
// Console component
export const Console = () => {
const appOutput = useAtomValue(appOutputAtom);
return (
<div className="font-mono text-xs px-4 h-full overflow-auto">
{appOutput.map((output, index) => (
<div key={index}>{output.message}</div>
))}
</div>
);
};

View File

@@ -0,0 +1,205 @@
import React, { useState, useRef, useEffect } from "react";
import Editor, { OnMount } from "@monaco-editor/react";
import { useLoadAppFile } from "@/hooks/useLoadAppFile";
import { useTheme } from "@/contexts/ThemeContext";
import { ChevronRight, Circle } from "lucide-react";
import "@/components/chat/monaco";
import { IpcClient } from "@/ipc/ipc_client";
interface FileEditorProps {
appId: number | null;
filePath: string;
}
interface BreadcrumbProps {
path: string;
hasUnsavedChanges: boolean;
}
const Breadcrumb: React.FC<BreadcrumbProps> = ({ path, hasUnsavedChanges }) => {
const segments = path.split("/").filter(Boolean);
return (
<div className="flex items-center justify-between px-4 py-2 text-sm text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-1 overflow-hidden">
<div className="flex items-center gap-1 overflow-hidden min-w-0">
{segments.map((segment, index) => (
<React.Fragment key={index}>
{index > 0 && (
<ChevronRight
size={14}
className="text-gray-400 flex-shrink-0"
/>
)}
<span className="hover:text-gray-900 dark:hover:text-gray-100 cursor-pointer truncate">
{segment}
</span>
</React.Fragment>
))}
</div>
<div className="flex-shrink-0 ml-2">
{hasUnsavedChanges && (
<Circle
size={8}
fill="currentColor"
className="text-amber-600 dark:text-amber-400"
/>
)}
</div>
</div>
</div>
);
};
export const FileEditor = ({ appId, filePath }: FileEditorProps) => {
const { content, loading, error } = useLoadAppFile(appId, filePath);
const { theme } = useTheme();
const [value, setValue] = useState<string | undefined>(undefined);
const [displayUnsavedChanges, setDisplayUnsavedChanges] = useState(false);
// Use refs for values that need to be current in event handlers
const originalValueRef = useRef<string | undefined>(undefined);
const editorRef = useRef<any>(null);
const isSavingRef = useRef<boolean>(false);
const needsSaveRef = useRef<boolean>(false);
const currentValueRef = useRef<string | undefined>(undefined);
// Update state when content loads
useEffect(() => {
if (content !== null) {
setValue(content);
originalValueRef.current = content;
currentValueRef.current = content;
needsSaveRef.current = false;
setDisplayUnsavedChanges(false);
}
}, [content, filePath]);
// Sync the UI with the needsSave ref
useEffect(() => {
setDisplayUnsavedChanges(needsSaveRef.current);
}, [needsSaveRef.current]);
// Determine if dark mode based on theme
const isDarkMode =
theme === "dark" ||
(theme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
const editorTheme = isDarkMode ? "dyad-dark" : "dyad-light";
// Handle editor mount
const handleEditorDidMount: OnMount = (editor, monaco) => {
editorRef.current = editor;
// Listen for model content change events
editor.onDidBlurEditorText(() => {
console.log("Editor text blurred, checking if save needed");
if (needsSaveRef.current) {
saveFile();
}
});
};
// Handle content change
const handleEditorChange = (newValue: string | undefined) => {
setValue(newValue);
currentValueRef.current = newValue;
const hasChanged = newValue !== originalValueRef.current;
needsSaveRef.current = hasChanged;
setDisplayUnsavedChanges(hasChanged);
};
// Save the file
const saveFile = async () => {
if (
!appId ||
!currentValueRef.current ||
!needsSaveRef.current ||
isSavingRef.current
)
return;
try {
isSavingRef.current = true;
const ipcClient = IpcClient.getInstance();
await ipcClient.editAppFile(appId, filePath, currentValueRef.current);
originalValueRef.current = currentValueRef.current;
needsSaveRef.current = false;
setDisplayUnsavedChanges(false);
} catch (error) {
console.error("Error saving file:", error);
// Could add error notification here
} finally {
isSavingRef.current = false;
}
};
// Determine language based on file extension
const getLanguage = (filePath: string) => {
const extension = filePath.split(".").pop()?.toLowerCase() || "";
const languageMap: Record<string, string> = {
js: "javascript",
jsx: "javascript",
ts: "typescript",
tsx: "typescript",
html: "html",
css: "css",
json: "json",
md: "markdown",
py: "python",
java: "java",
c: "c",
cpp: "cpp",
cs: "csharp",
go: "go",
rs: "rust",
rb: "ruby",
php: "php",
swift: "swift",
kt: "kotlin",
// Add more as needed
};
return languageMap[extension] || "plaintext";
};
if (loading) {
return <div className="p-4">Loading file content...</div>;
}
if (error) {
return <div className="p-4 text-red-500">Error: {error.message}</div>;
}
if (!content) {
return <div className="p-4 text-gray-500">No content available</div>;
}
return (
<div className="h-full flex flex-col">
<Breadcrumb path={filePath} hasUnsavedChanges={displayUnsavedChanges} />
<div className="flex-1 overflow-hidden">
<Editor
height="100%"
defaultLanguage={getLanguage(filePath)}
value={value}
theme={editorTheme}
onChange={handleEditorChange}
onMount={handleEditorDidMount}
options={{
minimap: { enabled: true },
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
fontFamily: "monospace",
fontSize: 13,
lineNumbers: "on",
}}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,127 @@
import React from "react";
import { Folder, FolderOpen } from "lucide-react";
import { selectedFileAtom } from "@/atoms/viewAtoms";
import { useSetAtom } from "jotai";
interface FileTreeProps {
files: string[];
}
interface TreeNode {
name: string;
path: string;
isDirectory: boolean;
children: TreeNode[];
}
// Convert flat file list to tree structure
const buildFileTree = (files: string[]): TreeNode[] => {
const root: TreeNode[] = [];
files.forEach((path) => {
const parts = path.split("/");
let currentLevel = root;
parts.forEach((part, index) => {
const isLastPart = index === parts.length - 1;
const currentPath = parts.slice(0, index + 1).join("/");
// Check if this node already exists at the current level
const existingNode = currentLevel.find((node) => node.name === part);
if (existingNode) {
// If we found the node, just drill down to its children for the next level
currentLevel = existingNode.children;
} else {
// Create a new node
const newNode: TreeNode = {
name: part,
path: currentPath,
isDirectory: !isLastPart,
children: [],
};
currentLevel.push(newNode);
currentLevel = newNode.children;
}
});
});
return root;
};
// File tree component
export const FileTree = ({ files }: FileTreeProps) => {
const treeData = buildFileTree(files);
return (
<div className="file-tree mt-2">
<TreeNodes nodes={treeData} level={0} />
</div>
);
};
interface TreeNodesProps {
nodes: TreeNode[];
level: number;
}
// Sort nodes to show directories first
const sortNodes = (nodes: TreeNode[]): TreeNode[] => {
return [...nodes].sort((a, b) => {
if (a.isDirectory === b.isDirectory) {
return a.name.localeCompare(b.name);
}
return a.isDirectory ? -1 : 1;
});
};
// Tree nodes component
const TreeNodes = ({ nodes, level }: TreeNodesProps) => (
<ul className="ml-4">
{sortNodes(nodes).map((node, index) => (
<TreeNode key={index} node={node} level={level} />
))}
</ul>
);
interface TreeNodeProps {
node: TreeNode;
level: number;
}
// Individual tree node component
const TreeNode = ({ node, level }: TreeNodeProps) => {
const [expanded, setExpanded] = React.useState(level < 2);
const setSelectedFile = useSetAtom(selectedFileAtom);
const handleClick = () => {
if (node.isDirectory) {
setExpanded(!expanded);
} else {
setSelectedFile({
path: node.path,
});
}
};
return (
<li className="py-0.5">
<div
className="flex items-center hover:bg-(--sidebar) rounded cursor-pointer px-1.5 py-0.5 text-sm"
onClick={handleClick}
>
{node.isDirectory && (
<span className="mr-1 text-gray-500">
{expanded ? <FolderOpen size={16} /> : <Folder size={16} />}
</span>
)}
<span>{node.name}</span>
</div>
{node.isDirectory && expanded && node.children.length > 0 && (
<TreeNodes nodes={node.children} level={level + 1} />
)}
</li>
);
};

View File

@@ -0,0 +1,414 @@
import { selectedAppIdAtom, appUrlAtom, appOutputAtom } from "@/atoms/appAtoms";
import { useAtomValue, useSetAtom } from "jotai";
import { useRunApp } from "@/hooks/useRunApp";
import { useEffect, useRef, useState } from "react";
import {
ArrowLeft,
ArrowRight,
RefreshCw,
ExternalLink,
Maximize2,
Loader2,
X,
Sparkles,
ChevronDown,
Lightbulb,
} from "lucide-react";
import { chatInputValueAtom } from "@/atoms/chatAtoms";
import { IpcClient } from "@/ipc/ipc_client";
import { useLoadApp } from "@/hooks/useLoadApp";
import { useLoadAppFile } from "@/hooks/useLoadAppFile";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface ErrorBannerProps {
error: string | null;
onDismiss: () => void;
onAIFix: () => void;
}
const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => {
if (!error) return null;
return (
<div className="absolute top-2 left-2 right-2 z-10 bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 rounded-md shadow-sm p-2">
{/* Close button in top left */}
<button
onClick={onDismiss}
className="absolute top-1 left-1 p-1 hover:bg-red-100 dark:hover:bg-red-900/50 rounded"
>
<X size={14} className="text-red-500 dark:text-red-400" />
</button>
{/* Error message in the middle */}
<div className="px-6 py-1 text-sm">
<div className="text-red-700 dark:text-red-300 text-wrap">{error}</div>
</div>
{/* Tip message */}
<div className="mt-2 px-6">
<div className="relative p-2 bg-red-100 dark:bg-red-900/50 rounded-sm flex gap-1 items-center">
<div>
<Lightbulb size={16} className=" text-red-800 dark:text-red-300" />
</div>
<span className="text-sm text-red-700 dark:text-red-400">
<span className="font-medium">Tip: </span>Check if refreshing the
page or restarting the app fixes the error.
</span>
</div>
</div>
{/* AI Fix button at the bottom */}
<div className="mt-2 flex justify-end">
<button
onClick={onAIFix}
className="cursor-pointer flex items-center space-x-1 px-2 py-0.5 bg-red-500 dark:bg-red-600 text-white rounded text-sm hover:bg-red-600 dark:hover:bg-red-700"
>
<Sparkles size={14} />
<span>Fix error with AI</span>
</button>
</div>
</div>
);
};
// Preview iframe component
export const PreviewIframe = ({
loading,
error,
}: {
loading: boolean;
error: Error | null;
}) => {
const selectedAppId = useAtomValue(selectedAppIdAtom);
const { appUrl } = useAtomValue(appUrlAtom);
const setAppOutput = useSetAtom(appOutputAtom);
const { app } = useLoadApp(selectedAppId);
// State to trigger iframe reload
const [reloadKey, setReloadKey] = useState(0);
const [iframeError, setIframeError] = useState<string | null>(null);
const [showError, setShowError] = useState(true);
const setInputValue = useSetAtom(chatInputValueAtom);
const [availableRoutes, setAvailableRoutes] = useState<
Array<{ path: string; label: string }>
>([]);
// Load router related files to extract routes
const { content: routerContent } = useLoadAppFile(
selectedAppId,
"src/App.tsx"
);
// Effect to parse routes from the router file
useEffect(() => {
if (routerContent) {
console.log("routerContent", routerContent);
try {
const routes: Array<{ path: string; label: string }> = [];
// Extract route imports and paths using regex for React Router syntax
// Match <Route path="/path">
const routePathsRegex = /<Route\s+(?:[^>]*\s+)?path=["']([^"']+)["']/g;
let match;
// Find all route paths in the router content
while ((match = routePathsRegex.exec(routerContent)) !== null) {
const path = match[1];
// Create a readable label from the path
const label =
path === "/"
? "Home"
: path
.split("/")
.filter((segment) => segment && !segment.startsWith(":"))
.pop()
?.replace(/[-_]/g, " ")
.replace(/^\w/, (c) => c.toUpperCase()) || path;
if (!routes.some((r) => r.path === path)) {
routes.push({ path, label });
}
}
setAvailableRoutes(routes);
} catch (e) {
console.error("Error parsing router file:", e);
}
}
}, [routerContent]);
// Navigation state
const [canGoBack, setCanGoBack] = useState(false);
const [canGoForward, setCanGoForward] = useState(false);
const [navigationHistory, setNavigationHistory] = useState<string[]>([]);
const [currentHistoryPosition, setCurrentHistoryPosition] = useState(0);
const iframeRef = useRef<HTMLIFrameElement>(null);
// Add message listener for iframe errors and navigation events
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// Only handle messages from our iframe
if (event.source !== iframeRef.current?.contentWindow) {
return;
}
const { type, payload } = event.data;
if (type === "window-error") {
const errorMessage = `Error in ${payload.filename} (line ${payload.lineno}, col ${payload.colno}): ${payload.message}`;
console.error("Iframe error:", errorMessage);
setIframeError(errorMessage);
setAppOutput((prev) => [
...prev,
{
message: `Iframe error: ${errorMessage}`,
type: "client-error",
appId: selectedAppId!,
},
]);
} else if (type === "unhandled-rejection") {
const errorMessage = `Unhandled Promise Rejection: ${payload.reason}`;
console.error("Iframe unhandled rejection:", errorMessage);
setIframeError(errorMessage);
setAppOutput((prev) => [
...prev,
{
message: `Iframe unhandled rejection: ${errorMessage}`,
type: "client-error",
appId: selectedAppId!,
},
]);
} else if (type === "pushState" || type === "replaceState") {
console.debug(`Navigation event: ${type}`, payload);
// Update navigation history based on the type of state change
if (type === "pushState") {
// For pushState, we trim any forward history and add the new URL
const newHistory = [
...navigationHistory.slice(0, currentHistoryPosition + 1),
payload.newUrl,
];
setNavigationHistory(newHistory);
setCurrentHistoryPosition(newHistory.length - 1);
} else if (type === "replaceState") {
// For replaceState, we replace the current URL
const newHistory = [...navigationHistory];
newHistory[currentHistoryPosition] = payload.newUrl;
setNavigationHistory(newHistory);
}
// Update navigation buttons state
setCanGoBack(currentHistoryPosition > 0);
setCanGoForward(currentHistoryPosition < navigationHistory.length - 1);
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [navigationHistory, currentHistoryPosition, selectedAppId]);
// Initialize navigation history when iframe loads
useEffect(() => {
if (appUrl) {
setNavigationHistory([appUrl]);
setCurrentHistoryPosition(0);
setCanGoBack(false);
setCanGoForward(false);
}
}, [appUrl]);
// Function to navigate back
const handleNavigateBack = () => {
if (canGoBack && iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.postMessage(
{
type: "navigate",
payload: { direction: "backward" },
},
"*"
);
// Update our local state
setCurrentHistoryPosition((prev) => prev - 1);
setCanGoBack(currentHistoryPosition - 1 > 0);
setCanGoForward(true);
}
};
// Function to navigate forward
const handleNavigateForward = () => {
if (canGoForward && iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.postMessage(
{
type: "navigate",
payload: { direction: "forward" },
},
"*"
);
// Update our local state
setCurrentHistoryPosition((prev) => prev + 1);
setCanGoBack(true);
setCanGoForward(
currentHistoryPosition + 1 < navigationHistory.length - 1
);
}
};
// Function to handle reload
const handleReload = () => {
setReloadKey((prevKey) => prevKey + 1);
// Optionally, add logic here if you need to explicitly stop/start the app again
// For now, just changing the key should remount the iframe
console.debug("Reloading iframe preview for app", selectedAppId);
};
// Function to navigate to a specific route
const navigateToRoute = (path: string) => {
if (iframeRef.current?.contentWindow && appUrl) {
// Create the full URL by combining the base URL with the path
const baseUrl = new URL(appUrl).origin;
const newUrl = `${baseUrl}${path}`;
// Navigate to the URL
iframeRef.current.contentWindow.location.href = newUrl;
// Update navigation history
const newHistory = [
...navigationHistory.slice(0, currentHistoryPosition + 1),
newUrl,
];
setNavigationHistory(newHistory);
setCurrentHistoryPosition(newHistory.length - 1);
setCanGoBack(true);
setCanGoForward(false);
}
};
// Display loading state
if (loading) {
return <div className="p-4 dark:text-gray-300">Loading app preview...</div>;
}
// Display message if no app is selected
if (selectedAppId === null) {
return (
<div className="p-4 text-gray-500 dark:text-gray-400">
Select an app to see the preview.
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Browser-style header */}
<div className="flex items-center p-2 border-b space-x-2 ">
{/* Navigation Buttons */}
<div className="flex space-x-1">
<button
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
disabled={!canGoBack || loading || !selectedAppId}
onClick={handleNavigateBack}
>
<ArrowLeft size={16} />
</button>
<button
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
disabled={!canGoForward || loading || !selectedAppId}
onClick={handleNavigateForward}
>
<ArrowRight size={16} />
</button>
<button
onClick={handleReload}
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
disabled={loading || !selectedAppId}
>
<RefreshCw size={16} />
</button>
</div>
{/* Address Bar with Routes Dropdown - using shadcn/ui dropdown-menu */}
<div className="relative flex-grow">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex items-center justify-between px-3 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm text-gray-700 dark:text-gray-200 cursor-pointer w-full">
<span>
{navigationHistory[currentHistoryPosition]
? new URL(navigationHistory[currentHistoryPosition])
.pathname
: "/"}
</span>
<ChevronDown size={14} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-full">
{availableRoutes.length > 0 ? (
availableRoutes.map((route) => (
<DropdownMenuItem
key={route.path}
onClick={() => navigateToRoute(route.path)}
className="flex justify-between"
>
<span>{route.label}</span>
<span className="text-gray-500 dark:text-gray-400 text-xs">
{route.path}
</span>
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>Loading routes...</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Action Buttons */}
<div className="flex space-x-1">
<button
onClick={() => {
if (appUrl) {
IpcClient.getInstance().openExternalUrl(appUrl);
}
}}
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
>
<ExternalLink size={16} />
</button>
</div>
</div>
<div className="relative flex-grow ">
<ErrorBanner
error={showError ? error?.message || iframeError : null}
onDismiss={() => setShowError(false)}
onAIFix={() => {
setInputValue(`Fix the error in ${error?.message || iframeError}`);
}}
/>
{!appUrl ? (
<div className="absolute inset-0 flex flex-col items-center justify-center space-y-4 bg-gray-50 dark:bg-gray-950">
<Loader2 className="w-8 h-8 animate-spin text-gray-400 dark:text-gray-500" />
<p className="text-gray-600 dark:text-gray-300">
Starting up your app...
</p>
</div>
) : (
<iframe
ref={iframeRef}
key={reloadKey}
title={`Preview for App ${selectedAppId}`}
className="w-full h-full border-none bg-white dark:bg-gray-950"
src={appUrl}
/>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,192 @@
import { useAtom, useAtomValue } from "jotai";
import { previewModeAtom, selectedAppIdAtom } from "../../atoms/appAtoms";
import { useLoadApp } from "@/hooks/useLoadApp";
import { CodeView } from "./CodeView";
import { PreviewIframe } from "./PreviewIframe";
import {
Eye,
Code,
ChevronDown,
ChevronUp,
Logs,
RefreshCw,
} from "lucide-react";
import { motion } from "framer-motion";
import { useEffect, useRef, useState, useCallback } from "react";
import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels";
import { Console } from "./Console";
import { useRunApp } from "@/hooks/useRunApp";
type PreviewMode = "preview" | "code";
interface PreviewHeaderProps {
previewMode: PreviewMode;
setPreviewMode: (mode: PreviewMode) => void;
onRestart: () => void;
}
// Preview Header component with preview mode toggle
const PreviewHeader = ({
previewMode,
setPreviewMode,
onRestart,
}: PreviewHeaderProps) => (
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
<div className="relative flex space-x-2 bg-[var(--background-darkest)] rounded-md p-0.5">
<button
className="relative flex items-center space-x-1 px-3 py-1 rounded-md text-sm z-10"
onClick={() => setPreviewMode("preview")}
>
{previewMode === "preview" && (
<motion.div
layoutId="activeIndicator"
className="absolute inset-0 bg-(--background-lightest) shadow rounded-md -z-1"
transition={{ type: "spring", stiffness: 500, damping: 35 }}
/>
)}
<Eye size={16} />
<span>Preview</span>
</button>
<button
className="relative flex items-center space-x-1 px-3 py-1 rounded-md text-sm z-10"
onClick={() => setPreviewMode("code")}
>
{previewMode === "code" && (
<motion.div
layoutId="activeIndicator"
className="absolute inset-0 bg-(--background-lightest) shadow rounded-md -z-1"
transition={{ type: "spring", stiffness: 500, damping: 35 }}
/>
)}
<Code size={16} />
<span>Code</span>
</button>
</div>
<button
onClick={onRestart}
className="flex items-center space-x-1 px-3 py-1 rounded-md text-sm hover:bg-[var(--background-darkest)] transition-colors"
title="Restart App"
>
<RefreshCw size={16} />
<span>Restart</span>
</button>
</div>
);
// Console header component
const ConsoleHeader = ({
isOpen,
onToggle,
}: {
isOpen: boolean;
onToggle: () => void;
}) => (
<div
onClick={onToggle}
className="flex items-center gap-2 px-4 py-1.5 border-t border-border cursor-pointer hover:bg-[var(--background-darkest)] transition-colors"
>
<Logs size={16} />
<span className="text-sm font-medium">System Messages</span>
<div className="flex-1" />
{isOpen ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
</div>
);
// Main PreviewPanel component
export function PreviewPanel() {
const [previewMode, setPreviewMode] = useAtom(previewModeAtom);
const selectedAppId = useAtomValue(selectedAppIdAtom);
const [isConsoleOpen, setIsConsoleOpen] = useState(false);
const { runApp, stopApp, restartApp, error, loading, app } = useRunApp();
const runningAppIdRef = useRef<number | null>(null);
const handleRestart = useCallback(() => {
if (selectedAppId !== null) {
restartApp(selectedAppId);
}
}, [selectedAppId, restartApp]);
useEffect(() => {
const previousAppId = runningAppIdRef.current;
// Check if the selected app ID has changed
if (selectedAppId !== previousAppId) {
// Stop the previously running app, if any
if (previousAppId !== null) {
console.debug("Stopping previous app", previousAppId);
stopApp(previousAppId);
// We don't necessarily nullify the ref here immediately,
// let the start of the next app update it or unmount handle it.
}
// Start the new app if an ID is selected
if (selectedAppId !== null) {
console.debug("Starting new app", selectedAppId);
runApp(selectedAppId); // Consider adding error handling for the promise if needed
runningAppIdRef.current = selectedAppId; // Update ref to the new running app ID
} else {
// If selectedAppId is null, ensure no app is marked as running
runningAppIdRef.current = null;
}
}
// Cleanup function: This runs when the component unmounts OR before the effect runs again.
// We only want to stop the app on actual unmount. The logic above handles stopping
// when the appId changes. So, we capture the running appId at the time the effect renders.
const appToStopOnUnmount = runningAppIdRef.current;
return () => {
if (appToStopOnUnmount !== null) {
const currentRunningApp = runningAppIdRef.current;
if (currentRunningApp !== null) {
console.debug(
"Component unmounting or selectedAppId changing, stopping app",
currentRunningApp
);
stopApp(currentRunningApp);
runningAppIdRef.current = null; // Clear ref on stop
}
}
};
// Dependencies: run effect when selectedAppId changes.
// runApp/stopApp are stable due to useCallback.
}, [selectedAppId, runApp, stopApp]);
return (
<div className="flex flex-col h-full">
<PreviewHeader
previewMode={previewMode}
setPreviewMode={setPreviewMode}
onRestart={handleRestart}
/>
<div className="flex-1 overflow-hidden">
<PanelGroup direction="vertical">
<Panel id="content" minSize={30}>
<div className="h-full overflow-y-auto">
{previewMode === "preview" ? (
<PreviewIframe loading={loading} error={error} />
) : (
<CodeView loading={loading} error={error} app={app} />
)}
</div>
</Panel>
{isConsoleOpen && (
<>
<PanelResizeHandle className="h-1 bg-border hover:bg-gray-400 transition-colors cursor-row-resize" />
<Panel id="console" minSize={10} defaultSize={30}>
<div className="flex flex-col h-full">
<ConsoleHeader
isOpen={true}
onToggle={() => setIsConsoleOpen(false)}
/>
<Console />
</div>
</Panel>
</>
)}
</PanelGroup>
</div>
{!isConsoleOpen && (
<ConsoleHeader isOpen={false} onToggle={() => setIsConsoleOpen(true)} />
)}
</div>
);
}

View File

@@ -0,0 +1,357 @@
import { useState, useEffect } from "react";
import { useRouter } from "@tanstack/react-router";
import {
ArrowLeft,
ExternalLink,
KeyRound,
Info,
Circle,
Settings as SettingsIcon,
GiftIcon,
Trash2,
} from "lucide-react";
import { useSettings } from "@/hooks/useSettings";
import { PROVIDER_TO_ENV_VAR, PROVIDERS } from "@/constants/models";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { IpcClient } from "@/ipc/ipc_client";
interface ProviderSettingsPageProps {
provider: string;
}
// Helper function to mask ENV API keys (still needed for env vars)
const maskEnvApiKey = (key: string | undefined): string => {
if (!key) return "Not Set";
if (key.length < 8) return "****";
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`;
};
export function ProviderSettingsPage({ provider }: ProviderSettingsPageProps) {
const {
settings,
envVars,
loading: settingsLoading,
error: settingsError,
updateSettings,
} = useSettings();
const [apiKeyInput, setApiKeyInput] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const router = useRouter();
// Find provider details
const providerInfo = PROVIDERS[provider as keyof typeof PROVIDERS];
const providerDisplayName =
providerInfo?.displayName ||
provider.charAt(0).toUpperCase() + provider.slice(1);
const providerWebsiteUrl = providerInfo?.websiteUrl;
const hasFreeTier = providerInfo?.hasFreeTier;
const envVarName = PROVIDER_TO_ENV_VAR[provider];
const envApiKey = envVars[envVarName];
const userApiKey = settings?.providerSettings?.[provider]?.apiKey;
// --- Configuration Logic --- Updated Priority ---
const isValidUserKey =
!!userApiKey &&
!userApiKey.startsWith("Invalid Key") &&
userApiKey !== "Not Set";
const hasEnvKey = !!envApiKey;
const isConfigured = isValidUserKey || hasEnvKey; // Configured if either is set
// Settings key takes precedence if it's valid
const activeKeySource = isValidUserKey
? "settings"
: hasEnvKey
? "env"
: "none";
// --- Accordion Logic ---
const defaultAccordionValue = [];
if (isValidUserKey || !hasEnvKey) {
// If user key is set OR env key is NOT set, open the settings accordion item
defaultAccordionValue.push("settings-key");
}
if (hasEnvKey) {
defaultAccordionValue.push("env-key");
}
// --- Save Handler ---
const handleSaveKey = async () => {
if (!apiKeyInput) {
setSaveError("API Key cannot be empty.");
return;
}
setIsSaving(true);
setSaveError(null);
try {
await updateSettings({
providerSettings: {
...settings?.providerSettings,
[provider]: {
...(settings?.providerSettings?.[provider] || {}),
apiKey: apiKeyInput,
},
},
});
setApiKeyInput(""); // Clear input on success
// Optionally show a success message
} catch (error: any) {
console.error("Error saving API key:", error);
setSaveError(error.message || "Failed to save API key.");
} finally {
setIsSaving(false);
}
};
// --- Delete Handler ---
const handleDeleteKey = async () => {
setIsSaving(true);
setSaveError(null);
try {
await updateSettings({
providerSettings: {
...settings?.providerSettings,
[provider]: {
...(settings?.providerSettings?.[provider] || {}),
apiKey: null,
},
},
});
// Optionally show a success message
} catch (error: any) {
console.error("Error deleting API key:", error);
setSaveError(error.message || "Failed to delete API key.");
} finally {
setIsSaving(false);
}
};
// Effect to clear input error when input changes
useEffect(() => {
if (saveError) {
setSaveError(null);
}
}, [apiKeyInput]);
return (
<div className="min-h-screen p-8">
<div className="max-w-4xl mx-auto">
<Button
onClick={() => router.history.back()}
variant="outline"
size="sm"
className="flex items-center gap-2 mb-4 bg-(--background-lightest) py-5"
>
<ArrowLeft className="h-4 w-4" />
Go Back
</Button>
<div className="mb-6">
<div className="flex items-center mb-1">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mr-3">
Configure {providerDisplayName}
</h1>
{settingsLoading ? (
<Skeleton className="h-6 w-6 rounded-full" />
) : (
<Circle
className={`h-5 w-5 ${
isConfigured
? "fill-green-500 text-green-600"
: "fill-yellow-400 text-yellow-500"
}`}
/>
)}
<span className="ml-2 text-sm text-gray-600 dark:text-gray-400">
{settingsLoading
? "Loading..."
: isConfigured
? "Setup Complete"
: "Not Setup"}
</span>
</div>
{!settingsLoading && hasFreeTier && (
<span className="text-blue-600 mt-2 dark:text-blue-400 text-sm font-medium bg-blue-100 dark:bg-blue-900/30 px-2 py-1 rounded-full inline-flex items-center">
<GiftIcon className="w-4 h-4 mr-1" />
Free tier available
</span>
)}
</div>
{providerWebsiteUrl && !settingsLoading && (
<Button
onClick={(e) => {
e.preventDefault();
IpcClient.getInstance().openExternalUrl(providerWebsiteUrl);
}}
className="mb-4 bg-(--background-lightest) cursor-pointer py-5"
variant="outline"
>
{isConfigured ? (
<SettingsIcon className="mr-2 h-4 w-4" />
) : (
<KeyRound className="mr-2 h-4 w-4" />
)}
{isConfigured ? "Manage API Keys" : "Setup API Key"}
<ExternalLink className="ml-2 h-4 w-4" />
</Button>
)}
{settingsLoading ? (
<div className="space-y-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
) : settingsError ? (
<Alert variant="destructive">
<AlertTitle>Error Loading Settings</AlertTitle>
<AlertDescription>
Could not load configuration data: {settingsError.message}
</AlertDescription>
</Alert>
) : (
<Accordion
type="multiple"
className="w-full space-y-4"
defaultValue={defaultAccordionValue}
>
<AccordionItem
value="settings-key"
className="border rounded-lg px-4 bg-(--background-lightest)"
>
<AccordionTrigger className="text-lg font-medium hover:no-underline cursor-pointer">
API Key from Settings
</AccordionTrigger>
<AccordionContent className="pt-4 ">
{isValidUserKey && (
<Alert variant="default" className="mb-4">
<KeyRound className="h-4 w-4" />
<AlertTitle className="flex justify-between items-center">
<span>Current Key (Settings)</span>
<Button
variant="destructive"
size="sm"
onClick={handleDeleteKey}
disabled={isSaving}
className="flex items-center gap-1 h-7 px-2"
>
<Trash2 className="h-4 w-4" />
{isSaving ? "Deleting..." : "Delete"}
</Button>
</AlertTitle>
<AlertDescription>
<p className="font-mono text-sm">{userApiKey}</p>
{activeKeySource === "settings" && (
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
This key is currently active.
</p>
)}
</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<label
htmlFor="apiKeyInput"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{isValidUserKey ? "Update" : "Set"} {providerDisplayName}{" "}
API Key
</label>
<div className="flex items-start space-x-2">
<Input
type="password"
id="apiKeyInput"
value={apiKeyInput}
onChange={(e) => setApiKeyInput(e.target.value)}
placeholder={`Enter new ${providerDisplayName} API Key here`}
className={`flex-grow ${
saveError ? "border-red-500" : ""
}`}
/>
<Button
onClick={handleSaveKey}
disabled={isSaving || !apiKeyInput}
>
{isSaving ? "Saving..." : "Save Key"}
</Button>
</div>
{saveError && (
<p className="text-xs text-red-600">{saveError}</p>
)}
<p className="text-xs text-gray-500 dark:text-gray-400">
Setting a key here will override the environment variable
(if set).
</p>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem
value="env-key"
className="border rounded-lg px-4 bg-(--background-lightest)"
>
<AccordionTrigger className="text-lg font-medium hover:no-underline cursor-pointer">
API Key from Environment Variable
</AccordionTrigger>
<AccordionContent className="pt-4">
{hasEnvKey ? (
<Alert variant="default">
<KeyRound className="h-4 w-4" />
<AlertTitle>
Environment Variable Key ({envVarName})
</AlertTitle>
<AlertDescription>
<p className="font-mono text-sm">
{maskEnvApiKey(envApiKey)}
</p>
{activeKeySource === "env" && (
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
This key is currently active (no settings key set).
</p>
)}
{activeKeySource === "settings" && (
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-1">
This key is currently being overridden by the key set
in Settings.
</p>
)}
</AlertDescription>
</Alert>
) : (
<Alert variant="default">
<Info className="h-4 w-4" />
<AlertTitle>Environment Variable Not Set</AlertTitle>
<AlertDescription>
The{" "}
<code className="font-mono bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">
{envVarName}
</code>{" "}
environment variable is not set.
</AlertDescription>
</Alert>
)}
<p className="text-xs text-gray-500 dark:text-gray-400 mt-3">
This key is set outside the application. If present, it will
be used only if no key is configured in the Settings section
above. Requires app restart to detect changes.
</p>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import type * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,66 @@
import type * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,60 @@
import type * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
sidebar: "h-16 w-16",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,85 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,133 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,255 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,21 @@
import type * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,29 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };

View File

@@ -0,0 +1,28 @@
"use client"
import type * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

137
src/components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,137 @@
import type * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,708 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import { Menu, PanelLeftIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "18rem";
const SIDEBAR_WIDTH_ICON = "5rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open]
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
setOpen((open) => !open);
}, [setOpen]);
// Auto-collapse on small screens
React.useEffect(() => {
const mql = window.matchMedia("(max-width: 480px)");
const handleResize = () => {
if (mql.matches) {
setOpen(false);
}
};
mql.addEventListener("change", handleResize);
handleResize(); // Check initial size
return () => mql.removeEventListener("change", handleResize);
}, [setOpen]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
toggleSidebar,
}),
[state, open, setOpen, toggleSidebar]
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"bg-sidebar",
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { state } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
);
}
return (
<div
className="group peer text-sidebar-foreground block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 flex h-svh w-(--sidebar-width) transition-[left,right,width,transform] duration-200 ease-linear",
side === "left"
? "left-0 translate-x-0 group-data-[collapsible=offcanvas]:translate-x-[-100%]"
: "right-0 translate-x-0 group-data-[collapsible=offcanvas]:translate-x-[100%]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l border-sidebar-border",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="sidebar"
className="cursor-pointer ml-1 hover:bg-sidebar"
// className={cn("hidden", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<Menu className="size-5" />
<span className="sr-only">Toggle Menu</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right" align="center">
Toggle Menu
</TooltipContent>
</Tooltip>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left][data-state=collapsed]_&]:cursor-e-resize in-data-[side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
// (Only the sidebarMenuButtonVariants constant is updated; the rest of the code remains unchanged)
// Updated base classes:
// • Changed flex direction to column and centered items.
// • Enforced a fixed width (w-20) for consistent space.
// • Removed text-left and gap changes to ensure the text label appears below the icon.
"peer/menu-button flex flex-col items-center gap-1 w-16 overflow-hidden p-2 text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0 [&>span]:mt-1",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const { state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed"}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,61 @@
"use client"
import type * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }