"use client"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AnimatePresence } from "motion/react"; import { useQuery } from "@tanstack/react-query"; import { toast } from "sonner"; import { cn } from "@turbostarter/ui"; import { Button } from "@turbostarter/ui-web/button"; import { Icons } from "@turbostarter/ui-web/icons"; import { ScrollArea } from "@turbostarter/ui-web/scroll-area"; import { api } from "~/lib/api/client"; import { MemoizedMarkdown } from "~/modules/common/markdown/memoized-markdown"; import { Prose } from "~/modules/common/prose"; import { useGraphStore } from "~/modules/diagram/stores/useGraphStore"; import { flowToGraph } from "~/modules/diagram/lib/graph-converter"; import { useGraphMutation, persistGraphData } from "../hooks/useGraphMutation"; import { useProposalDiff } from "../hooks/useProposalDiff"; import { BadgeChip } from "./BadgeChip"; import type { DiagramType } from "~/modules/diagram/types/graph"; type ProposalStatus = "idle" | "pending" | "accepted" | "rejected"; /** Shared accept handler — used by CopilotPanel, AssistantBubble, ProposalBar, DiagramCanvas */ export function acceptCurrentProposal(diagramId: string) { const store = useGraphStore.getState(); const patch = store.proposedPatch; store.acceptProposal(); if (patch) { persistGraphData(diagramId, patch); } } /** Shared reject handler */ export function rejectCurrentProposal() { useGraphStore.getState().rejectProposal(); } // Type helper for tool invocation parts from AI SDK interface ToolPart { type: string; toolCallId: string; state: "input-streaming" | "input-available" | "output-available" | "output-error"; output?: unknown; input?: unknown; } function isGenerateDiagramTool(part: { type: string }): part is ToolPart { return part.type === "tool-generateDiagram"; } interface CopilotPanelProps { diagramId: string; diagramType: DiagramType; initialDescription?: string; isSharedView?: boolean; } export function CopilotPanel({ diagramId, diagramType, initialDescription, isSharedView }: CopilotPanelProps) { const chatId = useMemo(() => `copilot-${diagramId}`, [diagramId]); const [input, setInput] = useState(""); const scrollRef = useRef(null); const inputRef = useRef(null); const userScrolledRef = useRef(false); const appliedToolCallIds = useRef(new Set()); const hasSentInitial = useRef(false); const { proposeGraphPatch } = useGraphMutation(diagramId, diagramType); const proposalStatus = useGraphStore((s) => s.proposalStatus); const lastProposalOutcome = useGraphStore((s) => s.lastProposalOutcome); const { changeSummary } = useProposalDiff(); // Subscribe to prefillChat from hover affordances / command palette const prefillChat = useGraphStore((s) => s.prefillChat); useEffect(() => { if (prefillChat) { setInput(prefillChat.text); // Position cursor at end of pre-filled text requestAnimationFrame(() => { const el = inputRef.current; if (el) { el.focus(); el.setSelectionRange(el.value.length, el.value.length); } }); useGraphStore.getState().clearPrefillChat(); } }, [prefillChat]); // Subscribe to selected nodes for badge chips const selectedNodeIds = useGraphStore((s) => s.selectedNodeIds); const storeNodes = useGraphStore((s) => s.nodes); const storeEdges = useGraphStore((s) => s.edges); const selectedElements = useMemo(() => { if (selectedNodeIds.length === 0) return []; const nodeMap = new Map(storeNodes.map((n) => [n.id, n])); return selectedNodeIds .map((id) => { const node = nodeMap.get(id); if (!node) return null; const data = node.data as { type?: string; label?: string }; return { id: node.id, type: data.type ?? "unknown", label: data.label ?? node.id }; }) .filter((e): e is { id: string; type: string; label: string } => e !== null); }, [selectedNodeIds, storeNodes]); const handleDismissBadge = useCallback((nodeId: string) => { const current = useGraphStore.getState().selectedNodeIds; useGraphStore.getState().setSelectedNodeIds(current.filter((id) => id !== nodeId)); }, []); // Scope indicator: show what context the AI will see const scopeInfo = useMemo(() => { if (selectedElements.length === 0) return null; const selectedIds = new Set(selectedNodeIds); const connectedCount = storeEdges.filter( (e) => selectedIds.has(e.source) || selectedIds.has(e.target), ).length; const label = selectedElements.length === 1 ? selectedElements[0]!.label : `${selectedElements.length} elements`; return `Context: ${label} + ${connectedCount} connected edge${connectedCount !== 1 ? "s" : ""}`; }, [selectedElements, selectedNodeIds, storeEdges]); // Dynamic placeholder text const placeholder = useMemo(() => { if (selectedElements.length === 0) return "Describe what you want to build..."; if (selectedElements.length === 1) return `Describe changes to ${selectedElements[0]!.label}...`; return `Describe changes to ${selectedElements.length} elements...`; }, [selectedElements]); // Fetch existing chat history on mount (H1 fix) const { data: initialMessages } = useQuery({ queryKey: ["copilot", "messages", chatId], queryFn: async () => { const res = await api.ai.copilot.messages.$get({ query: { chatId }, }); if (!res.ok) return []; return res.json(); }, staleTime: Infinity, }); // Memoize transport to avoid recreation on every render (M2 fix) const transport = useMemo( () => new DefaultChatTransport({ api: api.ai.copilot.$url().toString(), prepareSendMessagesRequest: ({ messages, id }) => { const lastMessage = messages.at(-1); // Serialize current graph state for AI context const currentNodes = useGraphStore.getState().nodes; const currentEdges = useGraphStore.getState().edges; const graphContext = currentNodes.length > 0 ? JSON.stringify( flowToGraph(currentNodes, currentEdges, { version: "1", title: "", diagramType, }), ) : undefined; // Build selected element context for targeted modifications const currentSelectedIds = useGraphStore.getState().selectedNodeIds; const selectedEls = currentSelectedIds.length > 0 ? currentSelectedIds .map((nid) => { const node = currentNodes.find((n) => n.id === nid); if (!node) return null; const data = node.data as { type?: string; label?: string }; return { id: node.id, type: data.type ?? "unknown", label: data.label ?? node.id }; }) .filter(Boolean) : undefined; return { body: { ...lastMessage, chatId: id, diagramId, diagramType, graphContext, selectedElements: selectedEls, }, }; }, }), [diagramId, diagramType], ); const { messages, sendMessage, status, error, stop, setMessages } = useChat({ id: chatId, transport, onError: (err) => { console.error("[copilot]", err); toast.error("Failed to get AI response"); }, }); // Seed chat with persisted history once loaded useEffect(() => { if (initialMessages && initialMessages.length > 0 && messages.length === 0) { setMessages( initialMessages.map((m) => ({ ...m, createdAt: new Date(), })), ); } }, [initialMessages, messages.length, setMessages]); // Auto-send initial description from wizard (chat-first onboarding) useEffect(() => { if ( initialDescription && !hasSentInitial.current && messages.length === 0 && !initialMessages?.length ) { hasSentInitial.current = true; void sendMessage({ text: initialDescription, metadata: {} }); // Clean only the desc URL param without navigation const url = new URL(window.location.href); url.searchParams.delete("desc"); window.history.replaceState({}, "", url.pathname + url.search); } }, [initialDescription, messages.length, initialMessages, sendMessage]); // Detect and apply graph patches from tool invocations useEffect(() => { for (const message of messages) { if (message.role !== "assistant") continue; for (const part of message.parts) { if ( isGenerateDiagramTool(part) && part.state === "output-available" && !appliedToolCallIds.current.has(part.toolCallId) ) { appliedToolCallIds.current.add(part.toolCallId); const result = part.output as | { success: true; data: Parameters[0] } | { success: false; errors: string[] }; if (result.success) { proposeGraphPatch(result.data); } else { toast.error("Diagram generation failed: invalid graph structure"); console.error("[copilot] Graph validation errors:", result.errors); } } } } }, [messages, proposeGraphPatch]); const isSubmitting = status === "submitted" || status === "streaming"; // Check if currently generating a diagram (tool call in progress) const isGeneratingDiagram = useMemo(() => { const lastMessage = messages.at(-1); if (!lastMessage || lastMessage.role !== "assistant") return false; return lastMessage.parts.some( (p) => isGenerateDiagramTool(p) && (p.state === "input-streaming" || p.state === "input-available"), ); }, [messages]); // Auto-scroll on new content, but pause if user scrolled up useEffect(() => { if (userScrolledRef.current) return; const viewport = scrollRef.current?.querySelector( "[data-radix-scroll-area-viewport]", ); if (viewport) { viewport.scrollTop = viewport.scrollHeight; } }, [messages]); // Detect user scroll useEffect(() => { const viewport = scrollRef.current?.querySelector( "[data-radix-scroll-area-viewport]", ); if (!viewport) return; let timeoutId: NodeJS.Timeout; const handleScroll = () => { const isAtBottom = Math.abs( viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight, ) < 80; userScrolledRef.current = !isAtBottom; clearTimeout(timeoutId); timeoutId = setTimeout(() => { userScrolledRef.current = false; }, 2000); }; viewport.addEventListener("scroll", handleScroll); return () => { viewport.removeEventListener("scroll", handleScroll); clearTimeout(timeoutId); }; }, []); const handleSend = useCallback(() => { const text = input.trim(); if (!text || isSubmitting) return; void sendMessage({ text, metadata: {}, }); setInput(""); userScrolledRef.current = false; }, [input, isSubmitting, sendMessage]); const handleAccept = useCallback(() => { acceptCurrentProposal(diagramId); }, [diagramId]); const handleReject = useCallback(() => { rejectCurrentProposal(); }, []); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { const currentProposalStatus = useGraphStore.getState().proposalStatus; // Proposal pending + Enter + empty textarea → accept if (e.key === "Enter" && !e.shiftKey && currentProposalStatus === "pending" && !input.trim()) { e.preventDefault(); handleAccept(); return; } // Proposal pending + Escape → reject (don't clear badges) if (e.key === "Escape" && currentProposalStatus === "pending") { e.preventDefault(); handleReject(); return; } // Normal Enter → send message if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); return; } // Normal Escape → clear badges if (e.key === "Escape" && selectedNodeIds.length > 0) { e.preventDefault(); useGraphStore.getState().setSelectedNodeIds([]); } }, [handleSend, handleAccept, handleReject, selectedNodeIds.length, input], ); return (
{/* Messages area */}
{messages.length === 0 && ( )} {messages.map((message) => (
{message.role === "user" ? ( ) : ( )}
))} {/* Typing indicator */} {status === "submitted" && (
)} {/* Diagram generation indicator */} {isGeneratingDiagram && (
Generating diagram...
)} {/* Error display */} {error && (
Something went wrong. Please try again.
)}
{/* Input area */}
{/* Badge chips for selected elements */} {selectedElements.length > 0 && (
{selectedElements.map((el) => ( ))} {scopeInfo && (

{scopeInfo}

)}
)}