diff --git a/src/components/FrameRenderer/RoadmapFloatingChat.tsx b/src/components/FrameRenderer/RoadmapFloatingChat.tsx index 00c87139c..55c26d51d 100644 --- a/src/components/FrameRenderer/RoadmapFloatingChat.tsx +++ b/src/components/FrameRenderer/RoadmapFloatingChat.tsx @@ -1,11 +1,18 @@ -import { Wand2 } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { Wand2, SendIcon, PauseCircleIcon } from 'lucide-react'; +import { useEffect, useRef, useState, useMemo, Fragment } from 'react'; import { roadmapJSONOptions } from '../../queries/roadmap'; import { queryClient } from '../../stores/query-client'; import { useQuery } from '@tanstack/react-query'; import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard'; import { lockBodyScroll } from '../../lib/dom'; import { useKeydown } from '../../hooks/use-keydown'; +import { + useRoadmapAIChat, + type RoadmapAIChatHistoryType, +} from '../../hooks/use-roadmap-ai-chat'; +import { flushSync } from 'react-dom'; +import type { JSONContent } from '@tiptap/core'; +import { slugify } from '../../lib/slugger'; type RoadmapChatProps = { roadmapId: string; @@ -14,12 +21,56 @@ type RoadmapChatProps = { export function RoadmapFloatingChat(props: RoadmapChatProps) { const { roadmapId } = props; const [isOpen, setIsOpen] = useState(false); + const scrollareaRef = useRef(null); + const [inputValue, setInputValue] = useState(''); const { data: roadmapDetail, isLoading: isRoadmapDetailLoading } = useQuery( roadmapJSONOptions(roadmapId), queryClient, ); + const totalTopicCount = useMemo(() => { + const allowedTypes = ['topic', 'subtopic', 'todo']; + return ( + roadmapDetail?.json?.nodes.filter((node) => + allowedTypes.includes(node.type || ''), + ).length ?? 0 + ); + }, [roadmapDetail]); + + const onSelectTopic = (topicId: string, topicTitle: string) => { + // For now just scroll to bottom and close overlay + const topicSlug = slugify(topicTitle) + '@' + topicId; + window.dispatchEvent( + new CustomEvent('roadmap.node.click', { + detail: { + resourceType: 'roadmap', + resourceId: roadmapId, + topicId: topicSlug, + isCustomResource: false, + }, + }), + ); + // ensure chat visible + flushSync(() => { + setIsOpen(true); + }); + }; + + const { + aiChatHistory, + isStreamingMessage, + streamedMessage, + handleChatSubmit, + handleAbort, + scrollToBottom, + } = useRoadmapAIChat({ + roadmapId, + totalTopicCount, + scrollareaRef, + onSelectTopic, + }); + useEffect(() => { lockBodyScroll(isOpen); }, [isOpen]); @@ -28,6 +79,31 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) { setIsOpen(false); }); + const submitInput = () => { + const trimmed = inputValue.trim(); + if (!trimmed) { + return; + } + + const json: JSONContent = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: trimmed, + }, + ], + }, + ], + }; + + setInputValue(''); + handleChatSubmit(json, isRoadmapDetailLoading); + }; + return ( <> {isOpen && ( @@ -39,45 +115,87 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) { > )} -
+
{isOpen && (
- {/* Messages area - scrollable */} -
+ {/* Messages area */} +
- Hey, how can I help you? - - } + jsx={Hey, how can I help you?} + isIntro /> + + {aiChatHistory.map( + (chat: RoadmapAIChatHistoryType, index: number) => ( + + + + ), + )} + + {isStreamingMessage && !streamedMessage && ( + + )} + + {streamedMessage && ( + + )}
- {/* Input area - sticky at bottom */} -
+ {/* Input area */} +
setInputValue(e.target.value)} autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (isStreamingMessage) { + return; + } + submitInput(); + } + }} placeholder="Ask me anything about this roadmap..." className="w-full resize-none p-3 outline-none" /> + +
)} {!isOpen && (