diff --git a/modules/playground/components/ai-chat-panel.tsx b/modules/playground/components/ai-chat-panel.tsx index dfc86196..4871b0d1 100644 --- a/modules/playground/components/ai-chat-panel.tsx +++ b/modules/playground/components/ai-chat-panel.tsx @@ -1,510 +1,702 @@ "use client"; import { TIMEOUTS } from "@/lib/constants/config"; -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetDescription, + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, } from "@/components/ui/sheet"; import { Button } from "@/components/ui/button"; import { - Bot, - Send, - Trash2, - Loader2, - Sparkles, - User, - Wrench, - Zap, - Code2, - ChevronDown, + Bot, + Send, + Trash2, + Loader2, + Sparkles, + User, + Wrench, + Zap, + Code2, + ChevronDown, } from "lucide-react"; import { - useAI, - type AIProvider, - addOrUpdateFile, - deleteFileByPath, - findFileByPath, - collectFilePaths + useAI, + type AIProvider, + deleteFileByPath, + findFileByPath, + collectFilePaths, + addOrUpdateFile, } from "@/modules/playground/hooks/useAI"; import { useFileExplorer } from "@/modules/playground/hooks/useFileExplorer"; import { toast } from "sonner"; import type { TemplateFolder } from "@/modules/playground/lib/path-to-json"; import { useChat } from "@ai-sdk/react"; +import AIDiffPreview from "./ai-diff-preview"; interface AIChatPanelProps { - templateData: TemplateFolder | null; - saveTemplateData: (data: TemplateFolder) => Promise; + templateData: TemplateFolder | null; + saveTemplateData: (data: TemplateFolder) => Promise; } interface MessagePart { - type?: string; - text?: string; - toolCallId?: string; - toolName?: string; - state?: string; - input?: Record; - [key: string]: unknown; + type?: string; + text?: string; + toolCallId?: string; + toolName?: string; + state?: string; + input?: Record; + [key: string]: unknown; } interface ExtendedMessage { - parts?: MessagePart[]; - content?: string; + parts?: MessagePart[]; + content?: string; +} + +interface PendingChange { + toolCallId: string; + toolName: string; + changes: Array<{ + path: string; + oldContent: string; + newContent: string; + }>; } const PROVIDERS: { id: AIProvider; label: string; icon: React.ReactNode }[] = [ - { id: "gemini", label: "Gemini", icon: }, - { id: "groq", label: "Groq", icon: }, - { id: "mistral", label: "Mistral", icon: }, + { id: "gemini", label: "Gemini", icon: }, + { id: "groq", label: "Groq", icon: }, + { id: "mistral", label: "Mistral", icon: }, ]; export default function AIChatPanel({ - templateData, - saveTemplateData, + templateData, + saveTemplateData, }: AIChatPanelProps) { - const { - isChatOpen, - closeChat, - provider, - setProvider, - getUserApiKey, - } = useAI(); - - const { openFiles, setOpenFiles, setTemplateData } = useFileExplorer(); - const [showProviderPicker, setShowProviderPicker] = useState(false); - - const messagesEndRef = useRef(null); - const inputRef = useRef(null); - const pickerRef = useRef(null); - - // Memoize the file tree string to avoid re-computing on every render - const fileTree = useMemo( - () => templateData ? collectFilePaths(templateData.items).join("\n") : "", - [templateData] - ); - - const [inputValue, setInputValue] = useState(""); - - const { - messages, - status, - setMessages, - addToolResult, - sendMessage: chatSendMessage, - } = useChat({ - onError: (err: Error) => { - console.error("AI Chat Error:", err); - toast.error(err.message || "An error occurred"); - } + const { isChatOpen, closeChat, provider, setProvider, getUserApiKey } = + useAI(); + + const { openFiles, setOpenFiles, setTemplateData } = useFileExplorer(); + const [showProviderPicker, setShowProviderPicker] = useState(false); + const [pendingChanges, setPendingChanges] = useState( + null, + ); + const [isReviewPending, setIsReviewPending] = useState(false); + + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const pickerRef = useRef(null); + + // Memoize the file tree string to avoid re-computing on every render + const fileTree = useMemo( + () => (templateData ? collectFilePaths(templateData.items).join("\n") : ""), + [templateData], + ); + + const [inputValue, setInputValue] = useState(""); + + const { + messages, + status, + setMessages, + addToolResult, + sendMessage: chatSendMessage, + } = useChat({ + onError: (err: Error) => { + console.error("AI Chat Error:", err); + toast.error(err.message || "An error occurred"); + }, + }); + + // v3 uses status instead of isLoading + const isLoading = status === "submitted" || status === "streaming"; + + // Prevent the user from sending a message if the MOST RECENT tool hasn't finished, + // to avoid the SDK "Tool result is missing" crash on the active chat stream. + // We explicitly only check the last message so older stuck tools don't permanently brick the chat. + const lastMessage = messages[messages.length - 1]; + const parts = lastMessage + ? (lastMessage as unknown as { parts?: unknown }).parts + : undefined; + const hasUnresolvedTools = + lastMessage?.role === "assistant" && + Array.isArray(parts) && + parts.some((rawP: unknown) => { + if (!rawP || typeof rawP !== "object") return false; + const p = rawP as MessagePart; + return ( + (p.type === "tool-invocation" || + (typeof p.type === "string" && p.type.startsWith("tool-"))) && + (!p.state || + (p.state !== "result" && p.state !== "output-available")) && + ((p.toolInvocation && + typeof p.toolInvocation === "object" && + (p.toolInvocation as Record).state === "call") || + (p.toolCallId && p.state === "call")) + ); }); - // v3 uses status instead of isLoading - const isLoading = status === "submitted" || status === "streaming"; - - // Prevent the user from sending a message if the MOST RECENT tool hasn't finished, - // to avoid the SDK "Tool result is missing" crash on the active chat stream. - // We explicitly only check the last message so older stuck tools don't permanently brick the chat. - const lastMessage = messages[messages.length - 1]; - const parts = lastMessage ? ((lastMessage as unknown) as { parts?: unknown }).parts : undefined; - const hasUnresolvedTools = lastMessage?.role === "assistant" && Array.isArray(parts) && parts.some( - (rawP: unknown) => { - if (!rawP || typeof rawP !== "object") return false; - const p = rawP as MessagePart; - return (p.type === "tool-invocation" || (typeof p.type === "string" && p.type.startsWith("tool-"))) && - (!p.state || (p.state !== "result" && p.state !== "output-available")) && - (p.toolInvocation && typeof p.toolInvocation === "object" && (p.toolInvocation as Record).state === "call"); - } + const sendMessage = useCallback(() => { + const trimmed = inputValue.trim(); + if (!trimmed || isLoading || hasUnresolvedTools) return; + chatSendMessage( + { text: trimmed }, + { + body: { + provider, + fileTree, + userApiKey: getUserApiKey(provider) || undefined, + }, + }, ); - - const sendMessage = useCallback(() => { - const trimmed = inputValue.trim(); - if (!trimmed || isLoading || hasUnresolvedTools) return; - chatSendMessage( - { text: trimmed }, - { - body: { - provider, - fileTree, - userApiKey: getUserApiKey(provider) || undefined, - }, - } - ); - setInputValue(""); - if (inputRef.current) { - inputRef.current.style.height = "auto"; - } - }, [inputValue, isLoading, hasUnresolvedTools, chatSendMessage, provider, fileTree, getUserApiKey]); - - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages]); - - useEffect(() => { - if (isChatOpen) setTimeout(() => inputRef.current?.focus(), TIMEOUTS.CHAT_INPUT_FOCUS); - }, [isChatOpen]); - - // Close provider picker on outside click - useEffect(() => { - const handleClick = (e: MouseEvent) => { - if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) { - setShowProviderPicker(false); - } - }; - if (showProviderPicker) document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, [showProviderPicker]); - - // Track which tool calls we've already executed to prevent double-execution - const processedToolCallIds = useRef(new Set()); - - // Handle incoming client-side tool calls - // In AI SDK v3, static tool parts use type: "tool-{toolName}" with: - // part.toolCallId, part.toolName, part.input, part.state - useEffect(() => { - const lastMessage = messages[messages.length - 1]; - if (lastMessage?.role !== "assistant") return; - - const rawParts: unknown[] = (lastMessage as unknown as { parts?: unknown[] }).parts ?? []; - - // Debug: log all parts to see what v3 sends - if (rawParts.length > 0) { - const toolParts = rawParts.filter((p) => typeof (p as Record).type === "string" && ((p as Record).type as string).startsWith("tool-")); - if (toolParts.length > 0) { - console.log("[AIChatPanel] Tool parts in last message:", JSON.stringify(toolParts, null, 2)); - } - } - - for (const rawPart of rawParts) { - const part = rawPart as Record; - const partType = part.type as string | undefined; - - // v3 static tool parts: type starts with "tool-" (e.g. "tool-read_file") - if (!partType?.startsWith("tool-")) continue; - - // Guard against re-execution: skip if already processed - const toolCallId = part.toolCallId as string | undefined; - if (!toolCallId) continue; - if (processedToolCallIds.current.has(toolCallId)) continue; - - // Only execute when input is fully available (not still streaming) - const state = part.state as string | undefined; - // Skip if output already provided, or if input is still streaming in - if (state === "output-available" || state === "output-streaming") continue; - // Skip if input hasn't arrived yet - if (state === "input-streaming") continue; - const toolName = (part.toolName as string | undefined) ?? partType.split("-").slice(1).join("-"); - // In v3, args live in part.input; fall back to part.args for compatibility - const args = (part.input as Record | undefined) ?? (part.args as Record | undefined) ?? {}; - - if (!toolCallId || !toolName) continue; - - let result: string; - - try { - if (toolName === "read_file") { - const { path } = args as { path?: string }; - if (!path || typeof path !== "string") { - result = `Error: read_file requires a "path" argument (e.g. "src/App.tsx")`; - } else { - const file = findFileByPath(templateData?.items || [], path); - result = (file && "content" in file && file.content !== undefined) ? file.content : `Error: File "${path}" not found`; - } - } else if (toolName === "edit_file") { - const { path, content } = args as { path?: string; content?: string }; - if (!path || typeof path !== "string") { - result = `Error: edit_file requires a "path" argument (e.g. "README.md")`; - } else if (content === undefined || content === null) { - result = `Error: edit_file requires a "content" argument with the full file contents`; - } else if (!templateData) { - result = `Error: Template data not loaded`; - } else { - const updatedItems = addOrUpdateFile(templateData.items, path, content as string); - const updatedTemplate = { ...templateData, items: updatedItems }; - setTemplateData(updatedTemplate); - - const updatedOpenFiles = openFiles.map((f) => { - const ext = f.fileExtension ? `.${f.fileExtension}` : ""; - const fullName = `${f.filename}${ext}`; - if (path.endsWith(fullName)) { - return { ...f, content: content as string, hasUnsavedChanges: true }; - } - return f; - }); - - setOpenFiles(updatedOpenFiles); - saveTemplateData(updatedTemplate).catch(console.error); - toast.success(`AI updated ${path}`); - result = `Successfully updated ${path}`; - } - } else if (toolName === "edit_multiple_files") { - const { changes } = args as { changes?: { path: string; content: string }[] }; - if (!changes || !Array.isArray(changes) || changes.length === 0) { - result = `Error: edit_multiple_files requires a "changes" array with at least one {path, content} entry`; - } else if (!templateData) { - result = `Error: Template data not loaded`; - } else { - let currentItems = templateData.items; - let currentOpenFiles = [...openFiles]; - - for (const change of changes) { - currentItems = addOrUpdateFile(currentItems, change.path, change.content); - currentOpenFiles = currentOpenFiles.map((f) => { - const ext = f.fileExtension ? `.${f.fileExtension}` : ""; - const fullName = `${f.filename}${ext}`; - if (change.path.endsWith(fullName)) { - return { ...f, content: change.content, hasUnsavedChanges: true }; - } - return f; - }); - } - - const updatedTemplate = { ...templateData, items: currentItems }; - setTemplateData(updatedTemplate); - setOpenFiles(currentOpenFiles); - saveTemplateData(updatedTemplate).catch(console.error); - toast.success(`AI scaffolded ${changes.length} files`); - result = `Successfully updated ${changes.length} files`; - } - } else if (toolName === "delete_file") { - const { path } = args as { path?: string }; - if (!path || typeof path !== "string") { - result = `Error: delete_file requires a "path" argument`; - } else if (!templateData) { - result = `Error: Template data not loaded`; - } else { - const updatedItems = deleteFileByPath(templateData.items, path); - const updatedTemplate = { ...templateData, items: updatedItems }; - setTemplateData(updatedTemplate); - - const updatedOpenFiles = openFiles.filter((f) => { - const ext = f.fileExtension ? `.${f.fileExtension}` : ""; - const fullName = `${f.filename}${ext}`; - return !path.endsWith(fullName); - }); - - setOpenFiles(updatedOpenFiles); - saveTemplateData(updatedTemplate).catch(console.error); - toast.success(`AI deleted ${path}`); - result = `Successfully deleted ${path}`; - } - } else { - result = `Error: Unknown tool ${toolName}`; - } - } catch (err: unknown) { - result = `Error: ${err instanceof Error ? err.message : String(err)}`; - } - - // Mark as processed BEFORE calling addToolResult to prevent re-execution on re-render - processedToolCallIds.current.add(toolCallId); - console.log(`[AIChatPanel] Executed tool ${toolName} (${toolCallId}), result:`, result.slice(0, 100)); - - addToolResult({ - toolCallId, - tool: toolName, - output: result, - } as Parameters[0]); + setInputValue(""); + if (inputRef.current) { + inputRef.current.style.height = "auto"; + } + }, [ + inputValue, + isLoading, + hasUnresolvedTools, + chatSendMessage, + provider, + fileTree, + getUserApiKey, + ]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + useEffect(() => { + if (isChatOpen) + setTimeout(() => inputRef.current?.focus(), TIMEOUTS.CHAT_INPUT_FOCUS); + }, [isChatOpen]); + + // Close provider picker on outside click + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) { + setShowProviderPicker(false); + } + }; + if (showProviderPicker) document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [showProviderPicker]); + + // Track which tool calls we've already executed to prevent double-execution + const processedToolCallIds = useRef(new Set()); + + const handleAcceptChanges = async () => { + if (!pendingChanges) return; + + // Apply the pending changes + let currentItems = templateData?.items || []; + let currentOpenFiles = [...openFiles]; + + for (const change of pendingChanges.changes) { + currentItems = addOrUpdateFile( + currentItems, + change.path, + change.newContent, + ); + currentOpenFiles = currentOpenFiles.map((f) => { + const ext = f.fileExtension ? `.${f.fileExtension}` : ""; + const fullName = `${f.filename}${ext}`; + if (change.path.endsWith(fullName)) { + return { ...f, content: change.newContent, hasUnsavedChanges: true }; } - }, [messages, templateData, openFiles, setTemplateData, setOpenFiles, saveTemplateData, addToolResult]); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - sendMessage(); + return f; + }); + } + + const updatedTemplate = { ...templateData!, items: currentItems }; + + try { + await saveTemplateData(updatedTemplate); + setTemplateData(updatedTemplate); + setOpenFiles(currentOpenFiles); + toast.success(`Applied ${pendingChanges.changes.length} AI change(s)`); + setPendingChanges(null); + setIsReviewPending(false); + } catch (error) { + console.error("Failed to save changes:", error); + toast.error("Failed to apply changes. Please try again."); + } + }; + + const handleRejectChanges = () => { + setPendingChanges(null); + setIsReviewPending(false); + toast.info("Changes discarded"); + }; + + // Handle incoming client-side tool calls + // In AI SDK v3, static tool parts use type: "tool-{toolName}" with: + // part.toolCallId, part.toolName, part.input, part.state + useEffect(() => { + const lastMessage = messages[messages.length - 1]; + if (lastMessage?.role !== "assistant") return; + + const rawParts: unknown[] = + (lastMessage as unknown as { parts?: unknown[] }).parts ?? []; + + // Debug: log safe metadata only + if (process.env.NODE_ENV === "development" && rawParts.length > 0) { + const toolParts = rawParts.filter( + (p) => + typeof (p as Record).type === "string" && + ((p as Record).type as string).startsWith("tool-"), + ); + if (toolParts.length > 0) { + console.log( + "[AIChatPanel] Tool parts count:", + toolParts.length, + "tool types:", + toolParts.map((p) => (p as Record).type), + ); + } + } + + for (const rawPart of rawParts) { + const part = rawPart as Record; + const partType = part.type as string | undefined; + + // v3 static tool parts: type starts with "tool-" (e.g. "tool-read_file") + if (!partType?.startsWith("tool-")) continue; + + // Guard against re-execution: skip if already processed + const toolCallId = part.toolCallId as string | undefined; + if (!toolCallId) continue; + if (processedToolCallIds.current.has(toolCallId)) continue; + + // Only execute when input is fully available (not still streaming) + const state = part.state as string | undefined; + // Skip if output already provided, or if input is still streaming in + if (state === "output-available" || state === "output-streaming") + continue; + // Skip if input hasn't arrived yet + if (state === "input-streaming") continue; + const toolName = + (part.toolName as string | undefined) ?? + partType.split("-").slice(1).join("-"); + // In v3, args live in part.input; fall back to part.args for compatibility + const args = + (part.input as Record | undefined) ?? + (part.args as Record | undefined) ?? + {}; + + if (!toolCallId || !toolName) continue; + + let result: string; + + try { + if (toolName === "read_file") { + const { path } = args as { path?: string }; + if (!path || typeof path !== "string") { + result = `Error: read_file requires a "path" argument (e.g. "src/App.tsx")`; + } else { + const file = findFileByPath(templateData?.items || [], path); + result = + file && "content" in file && file.content !== undefined + ? file.content + : `Error: File "${path}" not found`; + } + } else if (toolName === "edit_file") { + const { path, content } = args as { path?: string; content?: string }; + if (!path || typeof path !== "string") { + result = `Error: edit_file requires a "path" argument (e.g. "README.md")`; + } else if (content === undefined || content === null) { + result = `Error: edit_file requires a "content" argument with the full file contents`; + } else if (!templateData) { + result = `Error: Template data not loaded`; + } else { + // Get current file content if it exists + const existingFile = findFileByPath(templateData.items, path); + const oldContent = + existingFile && + "content" in existingFile && + existingFile.content !== undefined + ? existingFile.content + : ""; + + setPendingChanges({ + toolCallId, + toolName, + changes: [{ path, oldContent, newContent: content as string }], + }); + setIsReviewPending(true); + result = `Changes pending review: edit_file ${path}`; + } + } else if (toolName === "edit_multiple_files") { + const { changes } = args as { + changes?: { path: string; content: string }[]; + }; + if (!changes || !Array.isArray(changes) || changes.length === 0) { + result = `Error: edit_multiple_files requires a "changes" array with at least one {path, content} entry`; + } else if (!templateData) { + result = `Error: Template data not loaded`; + } else { + // Get current content for each file + const mappedChanges: Array<{ + path: string; + oldContent: string; + newContent: string; + }> = changes.map((change) => { + const existingFile = findFileByPath( + templateData.items, + change.path, + ); + const oldContent = + existingFile && + "content" in existingFile && + existingFile.content !== undefined + ? existingFile.content + : ""; + return { + path: change.path, + oldContent, + newContent: change.content, + }; + }); + setPendingChanges({ + toolCallId, + toolName, + changes: mappedChanges, + }); + setIsReviewPending(true); + result = `Changes pending review: edit_multiple_files affecting ${changes.length} file(s)`; + } + } else if (toolName === "delete_file") { + const { path } = args as { path?: string }; + if (!path || typeof path !== "string") { + result = `Error: delete_file requires a "path" argument`; + } else if (!templateData) { + result = `Error: Template data not loaded`; + } else { + const updatedItems = deleteFileByPath(templateData.items, path); + const updatedTemplate = { ...templateData, items: updatedItems }; + setTemplateData(updatedTemplate); + + const updatedOpenFiles = openFiles.filter((f) => { + const ext = f.fileExtension ? `.${f.fileExtension}` : ""; + const fullName = `${f.filename}${ext}`; + // Note: Using endsWith for now as the open file object doesn't contain + // a full path property. This is filename-based matching which may have + // false positives if multiple files share the same name. + return !path.endsWith(fullName); + }); + + setOpenFiles(updatedOpenFiles); + saveTemplateData(updatedTemplate).catch(console.error); + toast.success(`AI deleted ${path}`); + result = `Successfully deleted ${path}`; + } + } else { + result = `Error: Unknown tool ${toolName}`; } - }; - - const clearChat = () => setMessages([]); - const currentProvider = PROVIDERS.find((p) => p.id === provider) || PROVIDERS[0]; - - return ( - !open && closeChat()}> - - -
-
-
- -
-
- AI Assistant - - Project Context Enabled - -
-
- -
-
- -
- {messages.length === 0 && ( -
-
- -
-
-

How can I help you code?

-

- I can read your configuration, scaffold new components, or debug existing files. -

-
-
- )} - - {messages.map((msg) => { - const extended = msg as unknown as ExtendedMessage; - const rawParts: MessagePart[] = extended.parts ?? []; - - // AI SDK v3 stores user text in parts[].type=="text" - // Only genuine user messages have text parts - const textParts = rawParts.filter((p) => (p.type ?? "") === "text"); - const textContent: string = ( - textParts.map((p) => p.text ?? "").join("") || - extended.content || - "" - ); - - // v3 tool parts have type starting with "tool-" (e.g. "tool-read_file") - const toolParts: MessagePart[] = rawParts.filter( - (p) => (p.type ?? "").startsWith("tool-") - ); - - // Skip SDK-injected synthetic messages (no real text parts, no tool parts) - const isGenuineUser = msg.role === "user" && textParts.length > 0; - - return ( -
- {isGenuineUser && ( -
-
- {textContent} -
-
- -
-
- )} - {msg.role === "assistant" && ( -
-
- -
-
- {textContent && ( -
- {textContent} -
- )} - {toolParts.map((ti) => { - // In v3, tool name comes from the type suffix or toolName property - const tiName = (ti.toolName as string | undefined) ?? (ti.type as string)?.split("-").slice(1).join("-") ?? "tool"; - // Path arg lives in ti.input.path in v3 - const tiPath = (ti.input as Record | undefined)?.path as string | undefined; - const tiDone = ti.state === "output-available" || ti.state === "result"; - return ( -
-
- -
- - {tiName}({tiPath ? tiPath.split("/").pop() : ""}) {tiDone ? "✓" : } - -
- ); - })} -
-
- )} -
- ); - })} - - {isLoading && messages.length > 0 && messages[messages.length - 1].role !== "assistant" && ( -
- - Thinking... -
- )} -
+ } catch (err: unknown) { + result = `Error: ${err instanceof Error ? err.message : String(err)}`; + } + + // Mark as processed BEFORE calling addToolResult to prevent re-execution on re-render + processedToolCallIds.current.add(toolCallId); + if (process.env.NODE_ENV === "development") { + console.log(`[AIChatPanel] Executed tool ${toolName} (${toolCallId})`); + } + + addToolResult({ + toolCallId, + tool: toolName, + output: result, + } as Parameters[0]); + } + }, [ + messages, + templateData, + openFiles, + setTemplateData, + setOpenFiles, + saveTemplateData, + addToolResult, + ]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }; + + const clearChat = () => setMessages([]); + const currentProvider = + PROVIDERS.find((p) => p.id === provider) || PROVIDERS[0]; + + return ( + <> + + !open && closeChat()}> + + +
+
+
+
- -
-
-