(null);
+ const [isOpen, setIsOpen] = useState(false);
+ const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
+
+ const handleOpenChange = useCallback(
+ (open: boolean) => {
+ if (!open) {
+ lockScroll();
+ }
+ setIsOpen(open);
+ },
+ [lockScroll],
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+ReasoningRoot.displayName = "ReasoningRoot";
+
+/**
+ * Gradient overlay that softens the bottom edge during expand/collapse animations.
+ * Animation: Fades out with delay when opening and fades back in when closing.
+ */
+const GradientFade: FC<{ className?: string }> = ({ className }) => (
+
+);
+
+/**
+ * Trigger button for the Reasoning collapsible.
+ * Composed of icons, label, and text shimmer animation when reasoning is being streamed.
+ */
+const ReasoningTrigger: FC<{ active: boolean; className?: string }> = ({
+ active,
+ className,
+}) => (
+
+
+
+ Reasoning
+ {active ? (
+
+ Reasoning
+
+ ) : null}
+
+
+
+);
+
+/**
+ * Collapsible content wrapper that handles height expand/collapse animation.
+ * Animation: Height animates up (collapse) and down (expand).
+ * Also provides group context for child animations via data-state attributes.
+ */
+const ReasoningContent: FC<
+ PropsWithChildren<{
+ className?: string;
+ "aria-busy"?: boolean;
+ }>
+> = ({ className, children, "aria-busy": ariaBusy }) => (
+
+ {children}
+
+
+);
+
+ReasoningContent.displayName = "ReasoningContent";
+
+/**
+ * Text content wrapper that animates the reasoning text visibility.
+ * Animation: Slides in from top + fades in when opening, reverses when closing.
+ * Reacts to parent ReasoningContent's data-state via Radix group selectors.
+ */
+const ReasoningText: FC<
+ PropsWithChildren<{
+ className?: string;
+ }>
+> = ({ className, children }) => (
+
+ {children}
+
+);
+
+ReasoningText.displayName = "ReasoningText";
+
+/**
+ * Renders a single reasoning part's text with markdown support.
+ * Consecutive reasoning parts are automatically grouped by ReasoningGroup.
+ *
+ * Pass Reasoning to MessagePrimitive.Parts in thread.tsx
+ *
+ * @example:
+ * ```tsx
+ *
+ * ```
+ */
+const ReasoningImpl: ReasoningMessagePartComponent = () => ;
+
+/**
+ * Collapsible wrapper that groups consecutive reasoning parts together.
+ * Includes scroll lock to prevent page jumps during collapse animation.
+ *
+ * Pass ReasoningGroup to MessagePrimitive.Parts in thread.tsx
+ *
+ * @example:
+ * ```tsx
+ *
+ * ```
+ */
+const ReasoningGroupImpl: ReasoningGroupComponent = ({
+ children,
+ startIndex,
+ endIndex,
+}) => {
+ /**
+ * Detects if reasoning is currently streaming within this group's range.
+ */
+ const isReasoningStreaming = useAssistantState(({ message }) => {
+ if (message.status?.type !== "running") return false;
+ const lastIndex = message.parts.length - 1;
+ if (lastIndex < 0) return false;
+ const lastType = message.parts[lastIndex]?.type;
+ if (lastType !== "reasoning") return false;
+ return lastIndex >= startIndex && lastIndex <= endIndex;
+ });
+
+ return (
+
+
+
+
+ {children}
+
+
+ );
+};
+
+export const Reasoning = memo(ReasoningImpl);
+Reasoning.displayName = "Reasoning";
+
+export const ReasoningGroup = memo(ReasoningGroupImpl);
+ReasoningGroup.displayName = "ReasoningGroup";
diff --git a/recipes/_ts/ai_agent/snippets/sources/components/agent/thread-list.tsx b/recipes/_ts/ai_agent/snippets/sources/components/agent/thread-list.tsx
new file mode 100644
index 0000000..7244717
--- /dev/null
+++ b/recipes/_ts/ai_agent/snippets/sources/components/agent/thread-list.tsx
@@ -0,0 +1,95 @@
+import type { FC } from "react";
+import {
+ ThreadListItemPrimitive,
+ ThreadListPrimitive,
+ useAssistantState,
+} from "@assistant-ui/react";
+import { ArchiveIcon, PlusIcon } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { TooltipIconButton } from "@/components/agent/tooltip-icon-button";
+import { Skeleton } from "@/components/ui/skeleton";
+
+export const ThreadList: FC = () => {
+ return (
+
+
+
+
+ );
+};
+
+const ThreadListNew: FC = () => {
+ return (
+
+
+
+ );
+};
+
+const ThreadListItems: FC = () => {
+ const isLoading = useAssistantState(({ threads }) => threads.isLoading);
+
+ if (isLoading) {
+ return ;
+ }
+
+ return ;
+};
+
+const ThreadListSkeleton: FC = () => {
+ return (
+ <>
+ {Array.from({ length: 5 }, (_, i) => (
+
+
+
+ ))}
+ >
+ );
+};
+
+const ThreadListItem: FC = () => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+const ThreadListItemTitle: FC = () => {
+ return (
+
+
+
+ );
+};
+
+const ThreadListItemArchive: FC = () => {
+ return (
+
+
+
+
+
+ );
+};
diff --git a/recipes/_ts/ai_agent/snippets/sources/components/agent/thread-minimal.tsx b/recipes/_ts/ai_agent/snippets/sources/components/agent/thread-minimal.tsx
new file mode 100644
index 0000000..d36034d
--- /dev/null
+++ b/recipes/_ts/ai_agent/snippets/sources/components/agent/thread-minimal.tsx
@@ -0,0 +1,55 @@
+import { ThreadPrimitive, ComposerPrimitive, MessagePrimitive } from "@assistant-ui/react";
+import { ArrowUpIcon } from "lucide-react";
+import { FC } from "react";
+
+export const MinimalThread: FC = () => {
+ return (
+
+
+ (
+
+
+
+
+
+ ),
+ AssistantMessage: () => (
+
+
+
+
+
+ ),
+ }}
+ />
+
+
+
+
+ {
+ console.log("Key pressed:", e.key);
+ if (e.key === "Enter" && !e.shiftKey) {
+ console.log("Enter pressed - should submit");
+ }
+ }}
+ />
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/recipes/_ts/ai_agent/snippets/sources/components/agent/thread.tsx b/recipes/_ts/ai_agent/snippets/sources/components/agent/thread.tsx
new file mode 100644
index 0000000..af23ce0
--- /dev/null
+++ b/recipes/_ts/ai_agent/snippets/sources/components/agent/thread.tsx
@@ -0,0 +1,392 @@
+import {
+ ArrowDownIcon,
+ ArrowUpIcon,
+ CheckIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+ CopyIcon,
+ PencilIcon,
+ RefreshCwIcon,
+ Square,
+} from "lucide-react";
+
+import {
+ ActionBarPrimitive,
+ BranchPickerPrimitive,
+ ComposerPrimitive,
+ ErrorPrimitive,
+ MessagePrimitive,
+ ThreadPrimitive,
+} from "@assistant-ui/react";
+
+import type { FC } from "react";
+import { LazyMotion, MotionConfig, domAnimation } from "motion/react";
+import * as m from "motion/react-m";
+
+import { Button } from "@/components/ui/button";
+import { MarkdownText } from "@/components/agent/markdown-text";
+import { Reasoning, ReasoningGroup } from "@/components/agent/reasoning";
+import { ToolFallback } from "@/components/agent/tool-fallback";
+import { TooltipIconButton } from "@/components/agent/tooltip-icon-button";
+import {
+ ComposerAddAttachment,
+ ComposerAttachments,
+ UserMessageAttachments,
+} from "@/components/agent/attachment";
+
+import { cn } from "@/lib/utils";
+
+export const Thread: FC = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const ThreadScrollToBottom: FC = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+const ThreadWelcome: FC = () => {
+ return (
+
+
+
+
+ Hello there!
+
+
+ How can I help you today?
+
+
+
+
+
+ );
+};
+
+const ThreadSuggestions: FC = () => {
+ return (
+
+ {[
+ {
+ title: "What's the weather",
+ label: "in San Francisco?",
+ action: "What's the weather in San Francisco?",
+ },
+ {
+ title: "Explain React hooks",
+ label: "like useState and useEffect",
+ action: "Explain React hooks like useState and useEffect",
+ },
+ {
+ title: "Write a SQL query",
+ label: "to find top customers",
+ action: "Write a SQL query to find top customers",
+ },
+ {
+ title: "Create a meal plan",
+ label: "for healthy weight loss",
+ action: "Create a meal plan for healthy weight loss",
+ },
+ ].map((suggestedAction, index) => (
+
+
+
+
+
+ ))}
+
+ );
+};
+
+const Composer: FC = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const ComposerAction: FC = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const MessageError: FC = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+const AssistantMessage: FC = () => {
+ return (
+
+
+
+ );
+};
+
+const AssistantActionBar: FC = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const UserMessage: FC = () => {
+ return (
+
+
+
+ );
+};
+
+const UserActionBar: FC = () => {
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+const EditComposer: FC = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const BranchPicker: FC = ({
+ className,
+ ...rest
+}) => {
+ return (
+
+
+
+
+
+
+
+ /
+
+
+
+
+
+
+
+ );
+};
diff --git a/recipes/_ts/ai_agent/snippets/sources/components/agent/threadlist-sidebar.tsx b/recipes/_ts/ai_agent/snippets/sources/components/agent/threadlist-sidebar.tsx
new file mode 100644
index 0000000..2576151
--- /dev/null
+++ b/recipes/_ts/ai_agent/snippets/sources/components/agent/threadlist-sidebar.tsx
@@ -0,0 +1,72 @@
+import * as React from "react";
+import { Github, MessagesSquare } from "lucide-react";
+import Link from "next/link";
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarHeader,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarRail,
+} from "@/components/ui/sidebar";
+import { ThreadList } from "@/components/agent/thread-list";
+
+export function ThreadListSidebar({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ App name
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ GitHub
+
+ View Source
+
+
+
+
+
+
+
+ );
+}
diff --git a/recipes/_ts/ai_agent/snippets/sources/components/agent/tool-fallback.tsx b/recipes/_ts/ai_agent/snippets/sources/components/agent/tool-fallback.tsx
new file mode 100644
index 0000000..aca4030
--- /dev/null
+++ b/recipes/_ts/ai_agent/snippets/sources/components/agent/tool-fallback.tsx
@@ -0,0 +1,46 @@
+import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+
+export const ToolFallback: ToolCallMessagePartComponent = ({
+ toolName,
+ argsText,
+ result,
+}) => {
+ const [isCollapsed, setIsCollapsed] = useState(true);
+ return (
+
+
+
+
+ Used tool: {toolName}
+
+
+
+ {!isCollapsed && (
+
+
+ {result !== undefined && (
+
+
+ Result:
+
+
+ {typeof result === "string"
+ ? result
+ : JSON.stringify(result, null, 2)}
+
+
+ )}
+
+ )}
+
+ );
+};
diff --git a/recipes/_ts/ai_agent/snippets/sources/components/agent/tooltip-icon-button.tsx b/recipes/_ts/ai_agent/snippets/sources/components/agent/tooltip-icon-button.tsx
new file mode 100644
index 0000000..54b5fa2
--- /dev/null
+++ b/recipes/_ts/ai_agent/snippets/sources/components/agent/tooltip-icon-button.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+import { ComponentPropsWithRef, forwardRef } from "react";
+import { Slottable } from "@radix-ui/react-slot";
+
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+export type TooltipIconButtonProps = ComponentPropsWithRef & {
+ tooltip: string;
+ side?: "top" | "bottom" | "left" | "right";
+};
+
+export const TooltipIconButton = forwardRef<
+ HTMLButtonElement,
+ TooltipIconButtonProps
+>(({ children, tooltip, side = "bottom", className, ...rest }, ref) => {
+ return (
+
+
+
+
+ {tooltip}
+
+ );
+});
+
+TooltipIconButton.displayName = "TooltipIconButton";
diff --git a/recipes/_ts/ai_agent/snippets/sources/components/ui/avatar.tsx b/recipes/_ts/ai_agent/snippets/sources/components/ui/avatar.tsx
new file mode 100644
index 0000000..f7923d4
--- /dev/null
+++ b/recipes/_ts/ai_agent/snippets/sources/components/ui/avatar.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import * as React from "react";
+import * as AvatarPrimitive from "@radix-ui/react-avatar";
+
+import { cn } from "@/lib/utils";
+
+function Avatar({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Avatar, AvatarImage, AvatarFallback };
diff --git a/recipes/_ts/ai_agent/snippets/sources/components/ui/breadcrumb.tsx b/recipes/_ts/ai_agent/snippets/sources/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000..ee7e9a7
--- /dev/null
+++ b/recipes/_ts/ai_agent/snippets/sources/components/ui/breadcrumb.tsx
@@ -0,0 +1,109 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { ChevronRight, MoreHorizontal } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+function Breadcrumb({ ...props }: React.ComponentPropsWithoutRef<"nav">) {
+ return ;
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentPropsWithoutRef<"ol">) {
+ return (
+
+ );
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentPropsWithoutRef<"li">) {
+ return (
+
+ );
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentPropsWithoutRef<"a"> & {
+ asChild?: boolean;
+}) {
+ const Comp = asChild ? Slot : "a";
+
+ return (
+
+ );
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentPropsWithoutRef<"span">) {
+ return (
+
+ );
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentPropsWithoutRef<"li">) {
+ return (
+ svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? }
+
+ );
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentPropsWithoutRef<"span">) {
+ return (
+
+
+ More
+
+ );
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+};
diff --git a/recipes/_ts/ai_agent/snippets/sources/components/ui/button.tsx b/recipes/_ts/ai_agent/snippets/sources/components/ui/button.tsx
new file mode 100644
index 0000000..1dd187c
--- /dev/null
+++ b/recipes/_ts/ai_agent/snippets/sources/components/ui/button.tsx
@@ -0,0 +1,60 @@
+import * 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 hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white 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 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",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean;
+ }) {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/recipes/_ts/ai_agent/snippets/sources/components/ui/collapsible.tsx b/recipes/_ts/ai_agent/snippets/sources/components/ui/collapsible.tsx
new file mode 100644
index 0000000..90935c6
--- /dev/null
+++ b/recipes/_ts/ai_agent/snippets/sources/components/ui/collapsible.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
+
+function Collapsible({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function CollapsibleTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function CollapsibleContent({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent };
diff --git a/recipes/_ts/ai_agent/snippets/sources/components/ui/dialog.tsx b/recipes/_ts/ai_agent/snippets/sources/components/ui/dialog.tsx
new file mode 100644
index 0000000..230afc4
--- /dev/null
+++ b/recipes/_ts/ai_agent/snippets/sources/components/ui/dialog.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+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) {
+ return ;
+}
+
+function DialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DialogClose({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean;
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+ Close
+
+ )}
+
+
+ );
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+};
diff --git a/recipes/_ts/ai_agent/snippets/sources/components/ui/input.tsx b/recipes/_ts/ai_agent/snippets/sources/components/ui/input.tsx
new file mode 100644
index 0000000..a865f58
--- /dev/null
+++ b/recipes/_ts/ai_agent/snippets/sources/components/ui/input.tsx
@@ -0,0 +1,21 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ );
+}
+
+export { Input };
diff --git a/recipes/_ts/ai_agent/snippets/sources/components/ui/separator.tsx b/recipes/_ts/ai_agent/snippets/sources/components/ui/separator.tsx
new file mode 100644
index 0000000..22af2b9
--- /dev/null
+++ b/recipes/_ts/ai_agent/snippets/sources/components/ui/separator.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import * 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) {
+ return (
+
+ );
+}
+
+export { Separator };
diff --git a/recipes/_ts/ai_agent/snippets/sources/components/ui/sheet.tsx b/recipes/_ts/ai_agent/snippets/sources/components/ui/sheet.tsx
new file mode 100644
index 0000000..7c0cc51
--- /dev/null
+++ b/recipes/_ts/ai_agent/snippets/sources/components/ui/sheet.tsx
@@ -0,0 +1,139 @@
+"use client";
+
+import * 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) {
+ return ;
+}
+
+function SheetTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SheetClose({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SheetPortal({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SheetOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SheetContent({
+ className,
+ children,
+ side = "right",
+ ...props
+}: React.ComponentProps & {
+ side?: "top" | "right" | "bottom" | "left";
+}) {
+ return (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+ );
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SheetTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SheetDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ Sheet,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+};
diff --git a/recipes/_ts/ai_agent/snippets/sources/components/ui/sidebar.tsx b/recipes/_ts/ai_agent/snippets/sources/components/ui/sidebar.tsx
new file mode 100644
index 0000000..a41c701
--- /dev/null
+++ b/recipes/_ts/ai_agent/snippets/sources/components/ui/sidebar.tsx
@@ -0,0 +1,726 @@
+"use client";
+
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import { PanelLeftIcon } from "lucide-react";
+
+import { useIsMobile } from "@/hooks/use-mobile";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Separator } from "@/components/ui/separator";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet";
+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 = "16rem";
+const SIDEBAR_WIDTH_MOBILE = "18rem";
+const SIDEBAR_WIDTH_ICON = "3rem";
+const SIDEBAR_KEYBOARD_SHORTCUT = "b";
+
+type SidebarContextProps = {
+ state: "expanded" | "collapsed";
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ openMobile: boolean;
+ setOpenMobile: (open: boolean) => void;
+ isMobile: boolean;
+ toggleSidebar: () => void;
+};
+
+const SidebarContext = React.createContext(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;
+}) {
+ const isMobile = useIsMobile();
+ const [openMobile, setOpenMobile] = React.useState(false);
+
+ // 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(() => {
+ return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
+ }, [isMobile, setOpen, setOpenMobile]);
+
+ // 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(
+ () => ({
+ state,
+ open,
+ setOpen,
+ isMobile,
+ openMobile,
+ setOpenMobile,
+ toggleSidebar,
+ }),
+ [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
+ );
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+
+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 { isMobile, state, openMobile, setOpenMobile } = useSidebar();
+
+ if (collapsible === "none") {
+ return (
+
+ {children}
+
+ );
+ }
+
+ if (isMobile) {
+ return (
+
+
+
+ Sidebar
+ Displays the mobile sidebar.
+
+ {children}
+
+
+ );
+ }
+
+ return (
+
+ {/* This is what handles the sidebar gap on desktop */}
+
+
+
+ );
+}
+
+function SidebarTrigger({
+ className,
+ onClick,
+ ...props
+}: React.ComponentProps) {
+ const { toggleSidebar } = useSidebar();
+
+ return (
+
+ );
+}
+
+function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
+ const { toggleSidebar } = useSidebar();
+
+ return (
+
+ );
+}
+
+function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
+ return (
+
+ );
+}
+
+function SidebarInput({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SidebarSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SidebarGroupLabel({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "div";
+
+ return (
+ 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 (
+ 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 (
+
+ );
+}
+
+function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+
+ );
+}
+
+function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ );
+}
+
+const sidebarMenuButtonVariants = cva(
+ "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left 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 group-has-data-[sidebar=menu-action]/menu-item:pr-8 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 data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
+ {
+ 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 group-data-[collapsible=icon]:p-0!",
+ },
+ },
+ 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;
+} & VariantProps) {
+ const Comp = asChild ? Slot : "button";
+ const { isMobile, state } = useSidebar();
+
+ const button = (
+
+ );
+
+ if (!tooltip) {
+ return button;
+ }
+
+ if (typeof tooltip === "string") {
+ tooltip = {
+ children: tooltip,
+ };
+ }
+
+ return (
+
+ {button}
+
+
+ );
+}
+
+function SidebarMenuAction({
+ className,
+ asChild = false,
+ showOnHover = false,
+ ...props
+}: React.ComponentProps<"button"> & {
+ asChild?: boolean;
+ showOnHover?: boolean;
+}) {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+ 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 &&
+ "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground data-[state=open]:opacity-100 md:opacity-0",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function SidebarMenuBadge({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+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 (
+
+ {showIcon && (
+
+ )}
+
+
+ );
+}
+
+function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+
+ );
+}
+
+function SidebarMenuSubItem({
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+
+ );
+}
+
+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 (
+ span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
+ "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,
+};
diff --git a/recipes/_ts/ai_agent/snippets/sources/components/ui/skeleton.tsx b/recipes/_ts/ai_agent/snippets/sources/components/ui/skeleton.tsx
new file mode 100644
index 0000000..da6c299
--- /dev/null
+++ b/recipes/_ts/ai_agent/snippets/sources/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils";
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export { Skeleton };
diff --git a/recipes/_ts/ai_agent/snippets/sources/components/ui/tooltip.tsx b/recipes/_ts/ai_agent/snippets/sources/components/ui/tooltip.tsx
new file mode 100644
index 0000000..a79186a
--- /dev/null
+++ b/recipes/_ts/ai_agent/snippets/sources/components/ui/tooltip.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import * as React from "react";
+import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+
+import { cn } from "@/lib/utils";
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function Tooltip({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ );
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/recipes/_ts/ai_agent/snippets/sources/hooks/use-mobile.ts b/recipes/_ts/ai_agent/snippets/sources/hooks/use-mobile.ts
new file mode 100644
index 0000000..a93d583
--- /dev/null
+++ b/recipes/_ts/ai_agent/snippets/sources/hooks/use-mobile.ts
@@ -0,0 +1,21 @@
+import * as React from "react";
+
+const MOBILE_BREAKPOINT = 768;
+
+export function useIsMobile() {
+ const [isMobile, setIsMobile] = React.useState(
+ undefined,
+ );
+
+ React.useEffect(() => {
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
+ const onChange = () => {
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
+ };
+ mql.addEventListener("change", onChange);
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
+ return () => mql.removeEventListener("change", onChange);
+ }, []);
+
+ return !!isMobile;
+}
diff --git a/recipes/_ts/ai_agent/snippets/sources/hooks/useChatRuntime.ts b/recipes/_ts/ai_agent/snippets/sources/hooks/useChatRuntime.ts
new file mode 100644
index 0000000..b94536c
--- /dev/null
+++ b/recipes/_ts/ai_agent/snippets/sources/hooks/useChatRuntime.ts
@@ -0,0 +1,115 @@
+import { useCallback, useEffect, useState } from "react";
+import { useExternalStoreRuntime } from "@assistant-ui/react";
+import { API } from "../lib/api";
+
+ // QUOTA HOOK (internal)
+
+function useQuota() {
+ const [quotaInfo, setQuotaInfo] = useState(null);
+
+ const checkQuota = useCallback(async () => {
+ try {
+ const res = await fetch(API.QUOTA);
+ const data = await res.json();
+ setQuotaInfo(data);
+ return data;
+ } catch (err) {
+ console.error("Quota error:", err);
+ return null;
+ }
+ }, []);
+
+ useEffect(() => {
+ checkQuota();
+ }, [checkQuota]);
+
+ return { quotaInfo, checkQuota };
+}
+
+ // MAIN CHAT HOOK
+
+export function useChatRuntime() {
+ const [messages, setMessages] = useState([]);
+ const [isRunning, setIsRunning] = useState(false);
+
+ const { quotaInfo, checkQuota } = useQuota();
+
+ const addAssistant = (text: string) => {
+ setMessages((prev) => [
+ ...prev,
+ {
+ id: `assistant-${Date.now()}`,
+ role: "assistant",
+ content: [{ type: "text", text }],
+ attachments: [],
+ createdAt: new Date(),
+ status: { type: "complete" },
+ metadata: {},
+ unstable_state: {},
+ },
+ ]);
+ };
+
+ const runtime = useExternalStoreRuntime({
+ messages,
+ isRunning,
+
+ onNew: async (message) => {
+ const quota = await checkQuota();
+ if (quota && !quota.can_request) {
+ addAssistant(
+ `⚠️ **Rate Limit Exceeded**\n\nPlease wait **${quota.retry_after_seconds}s** before trying again.`
+ );
+ return;
+ }
+
+ setMessages((prev) => [
+ ...prev,
+ {
+ id: `user-${Date.now()}`,
+ role: "user",
+ content: message.content,
+ attachments: [],
+ createdAt: new Date(),
+ status: { type: "complete" },
+ metadata: {},
+ unstable_state: {},
+ },
+ ]);
+
+ setIsRunning(true);
+
+ const userText = message.content
+ .filter((p: any) => p.type === "text")
+ .map((p: any) => p.text)
+ .join("");
+
+ try {
+ const res = await fetch(API.CHAT, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: userText }),
+ });
+
+ if (res.status === 429) {
+ const err = await res.json();
+ addAssistant(
+ `⚠️ **Rate Limit Exceeded**\n\n${err.detail?.message || ""}`
+ );
+ await checkQuota();
+ return;
+ }
+
+ const data = await res.json();
+ addAssistant(data.reply);
+ await checkQuota();
+ } catch (err) {
+ addAssistant("❌ **Error** — Unable to reach the server.");
+ } finally {
+ setIsRunning(false);
+ }
+ },
+ });
+
+ return { runtime, quotaInfo };
+}
diff --git a/recipes/_ts/ai_agent/snippets/sources/layout.js b/recipes/_ts/ai_agent/snippets/sources/layout.js
new file mode 100644
index 0000000..7484cec
--- /dev/null
+++ b/recipes/_ts/ai_agent/snippets/sources/layout.js
@@ -0,0 +1,37 @@
+const getSourceCode = (appName = {}) => {
+ return `import type { Metadata } from "next";
+import { Geist, Geist_Mono } from "next/font/google";
+import "./globals.css";
+
+const geistSans = Geist({
+ variable: "--font-geist-sans",
+ subsets: ["latin"],
+});
+
+const geistMono = Geist_Mono({
+ variable: "--font-geist-mono",
+ subsets: ["latin"],
+});
+
+export const metadata: Metadata = {
+ title: "${appName}",
+ description: "Generated by react-chef",
+};
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+`;
+};
+
+module.exports = { getSourceCode };
diff --git a/recipes/_ts/ai_agent/snippets/sources/lib/api.ts b/recipes/_ts/ai_agent/snippets/sources/lib/api.ts
new file mode 100644
index 0000000..769a4a6
--- /dev/null
+++ b/recipes/_ts/ai_agent/snippets/sources/lib/api.ts
@@ -0,0 +1,10 @@
+const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL;
+
+if (!BASE_URL) {
+ throw new Error("NEXT_PUBLIC_API_BASE_URL is not defined");
+}
+
+export const API = {
+ CHAT: `${BASE_URL}/api/chat`,
+ QUOTA: `${BASE_URL}/api/quota`,
+};
diff --git a/recipes/_ts/ai_agent/snippets/sources/lib/utils.ts b/recipes/_ts/ai_agent/snippets/sources/lib/utils.ts
new file mode 100644
index 0000000..a5ef193
--- /dev/null
+++ b/recipes/_ts/ai_agent/snippets/sources/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/recipes/_ts/ai_agent/snippets/tsconfig.json b/recipes/_ts/ai_agent/snippets/tsconfig.json
new file mode 100644
index 0000000..03d0600
--- /dev/null
+++ b/recipes/_ts/ai_agent/snippets/tsconfig.json
@@ -0,0 +1,33 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+, "sources/layout.js" ],
+ "exclude": ["node_modules"]
+}
diff --git a/recipes/_ts/twixtui/snippets/sources/components/ContentPanel/ContentPanel.tsx b/recipes/_ts/twixtui/snippets/sources/components/ContentPanel/ContentPanel.tsx
index 644d890..fd7728a 100644
--- a/recipes/_ts/twixtui/snippets/sources/components/ContentPanel/ContentPanel.tsx
+++ b/recipes/_ts/twixtui/snippets/sources/components/ContentPanel/ContentPanel.tsx
@@ -1,8 +1,8 @@
-import { ReactNode } from 'react'
+import React from 'react'
import { ContentPane } from '@web-slate/twixt-ui-react'
type ContentPanelProps = {
- children: ReactNode
+ children: React.ReactNode
}
export default function ContentPanel({ children }: ContentPanelProps) {
diff --git a/recipes/install.js b/recipes/install.js
index b3234a9..d2f60cc 100644
--- a/recipes/install.js
+++ b/recipes/install.js
@@ -28,6 +28,9 @@ const twixtUISnippet = require("./twixtui/snippets");
const twixtUITypeScriptConfig = require("./_ts/twixtui/config");
const twixtUITypeScriptSnippet = require("./_ts/twixtui/snippets");
+const agentConfig = require("./_ts/ai_agent/config");
+const agentSnippet = require("./_ts/ai_agent/snippets");
+
const {
log,
error,
@@ -64,18 +67,24 @@ const install = function (directory, appName = '') {
let getDynamicSourceCode = slimSnippet.getDynamicSourceCode;
let baseConfig = getConfig();
+ let agentGetConfig = agentConfig.getConfig;
+
+
+
const baseDirPath = `./${appName}`;
const defaultProjectType = 'slim';
const twixtUIProjectType = 'twixtui';
const slimTypescriptProjectType = 'slim typescript';
const basicTypescriptProjectType = 'basic typescript';
const twixtUITypescriptProjectType = 'twixtui typescript';
+ const agentProjectType = 'ai agent';
let projectType = defaultProjectType;
const isSlimProject = (type) => type === defaultProjectType;
const isTwixtUIProject = (type) => type === twixtUIProjectType;
const isSlimTypeScriptProject = (type) => type === slimTypescriptProjectType;
const isBasicTypeScriptProject = (type) => type === basicTypescriptProjectType;
const isTwixtUITypeScriptProject = (type) => type === twixtUITypescriptProjectType;
+ const isAIAgentProject = (type) => type === agentProjectType;
const isTypeScriptProject = (type) => isSlimTypeScriptProject(type) || isBasicTypeScriptProject(type) || isTwixtUITypeScriptProject(type);
const isAnyTwixtUIProject = (type) =>
isTwixtUIProject(type) || isTwixtUITypeScriptProject(type);
@@ -95,7 +104,7 @@ const install = function (directory, appName = '') {
type: "list",
name: "projectType",
message: "choose your project type",
- choices: ["Slim", "Slim TypeScript", "Basic", "Basic TypeScript", "TwixtUI", "TwixtUI TypeScript"],
+ choices: ["Slim", "Slim TypeScript", "Basic", "Basic TypeScript", "TwixtUI", "TwixtUI TypeScript", "AI Agent"],
default: "Slim",
},
]);
@@ -139,6 +148,15 @@ const install = function (directory, appName = '') {
getWebPackConfig = twixtUITypeScriptSnippet.getWebPackConfig;
getDynamicSourceCode = twixtUITypeScriptSnippet.getDynamicSourceCode;
baseConfig = getConfig();
+ } else if (isAIAgentProject(projectType)) {
+ log(`AI Agent - projectType: ${projectType}`);
+ getConfig = agentConfig.getConfig;
+ getModulesList = agentConfig.getModulesList;
+ getDevModulesList = agentConfig.getDevModulesList;
+ getFileContent = agentSnippet.getFileContent;
+ getWebPackConfig = agentSnippet.getWebPackConfig;
+ getDynamicSourceCode = agentSnippet.getDynamicSourceCode;
+ baseConfig = agentGetConfig()
} else if (!isSlimProject(projectType)) {
log(`not slim - projectType: ${projectType}`);
getConfig = basicConfig.getConfig;
@@ -234,22 +252,25 @@ const install = function (directory, appName = '') {
createFile('.gitignore', getFileContent(gitIgnoreFileName));
}
- const babelConfigFileName = `.babelrc`;
- createFile(babelConfigFileName, getFileContent(babelConfigFileName));
-
if (isTypeScriptProjectType) {
const tsConfigFileName = `tsconfig.json`;
createFile(tsConfigFileName, getFileContent(tsConfigFileName));
}
- createFile(
- 'webpack.config.js',
- getWebPackConfig(appName, {
- ...baseConfig,
- portNumber: answers.portNumber,
- buildDir: answers.buildDir || baseConfig.buildDir
- })
- );
+ if (!isAIAgentProject(projectType)) {
+ const babelConfigFileName = `.babelrc`;
+ createFile(babelConfigFileName, getFileContent(babelConfigFileName));
+
+ createFile(
+ 'webpack.config.js',
+ getWebPackConfig(appName, {
+ ...baseConfig,
+ portNumber: answers.portNumber,
+ buildDir: answers.buildDir || baseConfig.buildDir
+ })
+ );
+ }
+
if (answers.eslint) {
const eslintConfigFileName = `.eslintrc.json`;
@@ -264,8 +285,11 @@ const install = function (directory, appName = '') {
);
}
- shell.mkdir(baseConfig.sourceDir.main);
- shell.cd(baseConfig.sourceDir.main);
+ if (!isAIAgentProject(projectType)) {
+ shell.mkdir(baseConfig.sourceDir.main);
+ shell.cd(baseConfig.sourceDir.main);
+ }
+
let projectTypeName;
@@ -281,15 +305,19 @@ const install = function (directory, appName = '') {
const isTS = isTwixtUITypeScriptProject(projectType);
- const sourceSubBase = isTypeScriptProjectType ? '_ts/' : '';
- const sourceSnippetDir = `${__dirname}/${sourceSubBase}${projectTypeName}/snippets/sources`;
+ let sourceSnippetDir;
+
+ if (!isAIAgentProject(projectType)) {
+ const sourceSubBase = isTypeScriptProjectType ? '_ts/' : '';
+ sourceSnippetDir = `${__dirname}/${sourceSubBase}${projectTypeName}/snippets/sources`;
+ }
const indexSourceFileName = `index.js`;
const appSourceFileName = `App.js`;
- // ✅ Non-TwixtUI projects only The condition
-
- if (!isAnyTwixtUIProject(projectType)) {
+ // ✅ Non-TwixtUI and agent projects only The condition
+
+ if (!isAnyTwixtUIProject(projectType) && !isAIAgentProject(projectType)) {
createFile(
`index.${componentExtension}`,
getDynamicSourceCode(indexSourceFileName, appName, baseConfig)
@@ -316,9 +344,10 @@ const install = function (directory, appName = '') {
}
- if (baseConfig.canAdd.hooks) {
- // Copy Hooks.
- shell.cp("-Rf", `${sourceSnippetDir}/hooks`, ".");
+ if (!isAIAgentProject(projectType)) {
+ if (baseConfig.canAdd.hooks) {
+ shell.cp("-Rf", `${sourceSnippetDir}/hooks`, ".");
+ }
}
if (baseConfig.canAdd.environment) {
@@ -415,44 +444,83 @@ const install = function (directory, appName = '') {
- log(chalk.green.underline.bold("Installing App dependencies..."));
- const dependencyList = [
- ...getModulesList(),
- ...(answers.hookForm ? ["form"] : []),
- ];
- moduleSetInstall("-S", dependencyList);
-
- log(chalk.green.underline.bold("Installing App dev dependencies..."));
- const devDependencyList = [
- ...getDevModulesList(),
- ...(answers.eslint ? ["eslint"] : []),
- ...(answers.prettier ? ["prettier"] : []),
- ...(answers.husky ? ["husky"] : []),
- ];
- moduleSetInstall("-D", devDependencyList);
-
- shell.cd("..");
- const packageFileContent = shell.cat("package.json")
- const packageFileObject = JSON.parse(packageFileContent)
- packageFileObject.main = `${baseConfig.sourceDir.main}/index.js`
- packageFileObject.private = true
- packageFileObject.scripts = {
- dev: "webpack serve --mode development",
- build: "webpack --mode production --progress",
- ...(answers.eslint
- ? {
- lint: "eslint src --ext .js",
- }
- : {}),
- ...(answers.prettier
- ? {
- prettier: "prettier --write src",
- }
- : {}),
- clean: "rm -rf node_modules",
- ...(isTwixtUIProject(projectType) ? getTwixtUIScripts() : {})
- };
- delete packageFileObject.main;
+
+ // Ai Agent Folder
+ if (isAIAgentProject(projectType)) {
+ const agentSnippetDir = `${__dirname}/_ts/ai_agent/snippets/sources`;
+
+ shell.cp('-Rf', `${agentSnippetDir}/app`, './app');
+
+ createFile(
+ "app/layout.tsx",
+ getDynamicSourceCode("layout.tsx", appName)
+ );
+ shell.cp('-Rf', `${agentSnippetDir}/components`, './components');
+ shell.cp('-Rf', `${agentSnippetDir}/hooks`, './hooks');
+ shell.cp('-Rf', `${agentSnippetDir}/lib`, './lib');
+
+ createFile('next.config.ts', getFileContent('next.config.ts'));
+ createFile('tsconfig.json', getFileContent('tsconfig.json'));
+ createFile('postcss.config.mjs', getFileContent('postcss.config.mjs'));
+ createFile('eslint.config.mjs', getFileContent('eslint.config.mjs'));
+ createFile('.env.local', getFileContent('.env.local'));
+ }
+
+
+
+ if (!isAIAgentProject(projectType)) {
+ log(chalk.green.bold("Installing App dependencies..."));
+ moduleSetInstall("-S", [
+ ...getModulesList(),
+ ...(answers.hookForm ? ["form"] : []),
+ ]);
+
+ log(chalk.green.bold("Installing App dev dependencies..."));
+ moduleSetInstall("-D", [
+ ...getDevModulesList(),
+ ...(answers.eslint ? ["eslint"] : []),
+ ...(answers.prettier ? ["prettier"] : []),
+ ...(answers.husky ? ["husky"] : []),
+ ]);
+ } else {
+ log(chalk.green.bold("Installing Agent dependencies..."));
+ moduleSetInstall("-S", agentConfig.getModulesList());
+
+ log(chalk.green.bold("Installing Agent dev dependencies..."));
+ moduleSetInstall("-D", agentConfig.getDevModulesList());
+ }
+
+
+
+
+ if (!isAIAgentProject(projectType)) {
+ shell.cd("..");
+ }
+ const packageFileContent = shell.cat("package.json");
+ const packageFileObject = JSON.parse(packageFileContent);
+ packageFileObject.private = true;
+
+ if (isAIAgentProject(projectType)) {
+ delete packageFileObject.main;
+ packageFileObject.scripts = {
+ dev: "next dev",
+ build: "next build",
+ start: "next start",
+ lint: "next lint",
+ clean: "rm -rf node_modules",
+ };
+ } else {
+ packageFileObject.main = `${baseConfig.sourceDir.main}/index.js`;
+ packageFileObject.scripts = {
+ dev: "webpack serve --mode development",
+ build: "webpack --mode production --progress",
+ ...(answers.eslint ? { lint: "eslint src --ext .js" } : {}),
+ ...(answers.prettier ? { prettier: "prettier --write src" } : {}),
+ clean: "rm -rf node_modules",
+ ...(isTwixtUIProject(projectType) ? getTwixtUIScripts() : {}),
+ };
+ }
+
shell.rm("package.json");
createFile("package.json", JSON.stringify(packageFileObject, null, 2));
})
diff --git a/recipes/moduleMatrix.js b/recipes/moduleMatrix.js
index 6b7bcfe..41e17bb 100644
--- a/recipes/moduleMatrix.js
+++ b/recipes/moduleMatrix.js
@@ -7,6 +7,14 @@ const babelWithTypeScript = [...babel, '@babel/preset-typescript'];
const typeScriptTooling = ['ts-loader','@types/react', '@types/react-dom'];
const typeScript = ['typescript', '@babel/runtime'];
const wepPackStyleLoaders = ['style-loader', 'css-loader']
+const agent = ['next','react','react-dom','ai','@ai-sdk/openai','@assistant-ui/react','@assistant-ui/react-ai-sdk',
+ '@assistant-ui/react-markdown','@radix-ui/react-avatar','@radix-ui/react-collapsible','@radix-ui/react-dialog',
+ '@radix-ui/react-separator','@radix-ui/react-slot','@radix-ui/react-tooltip','zustand','clsx','class-variance-authority',
+ 'tailwind-merge','tw-animate-css','framer-motion','motion','lucide-react','remark-gfm'];
+const agentDev = ['typescript','@types/react','@types/react-dom','@types/node','eslint','eslint-config-next',
+ '@eslint/eslintrc','prettier','prettier-plugin-tailwindcss','tailwindcss','@tailwindcss/postcss',
+ 'postcss','autoprefixer'];
+
module.exports = {
react: [...react],
@@ -26,6 +34,8 @@ module.exports = {
slimTypescriptDev: [...typeScriptTooling, ...webpack, ...webpackPlugins, ...webpackLoaders, ...babelWithTypeScript],
basicTypescriptDev: [...typeScriptTooling, ...webpack, ...webpackPlugins, ...webpackLoaders, ...babelWithTypeScript],
twixtUIDev: [...webpack, ...webpackPlugins, ...webpackLoaders, ...wepPackStyleLoaders, ...babel],
+ agent,
+ agentDev,
husky: 'npm i -D husky',
eslint: 'npx install-peerdeps --dev eslint-config-airbnb',
prettier: 'npm install --save-dev --save-exact prettier && npm i -D eslint-config-prettier'
diff --git a/recipes/utils.js b/recipes/utils.js
index 593e957..0d78609 100644
--- a/recipes/utils.js
+++ b/recipes/utils.js
@@ -42,7 +42,6 @@ const tryAccess = (accessPath) => {
})
})
}
-
const moduleSetInstall = async (option = '', moduleListArray = []) => {
if (!option) {
return