diff --git a/src/components/AIRoadmap/AIRoadmap.tsx b/src/components/AIRoadmap/AIRoadmap.tsx index 2c0fef3ec..615287808 100644 --- a/src/components/AIRoadmap/AIRoadmap.tsx +++ b/src/components/AIRoadmap/AIRoadmap.tsx @@ -1,7 +1,7 @@ import './AIRoadmap.css'; import { useQuery } from '@tanstack/react-query'; -import { useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; import { useToast } from '../../hooks/use-toast'; import { queryClient } from '../../stores/query-client'; @@ -9,10 +9,14 @@ import { AITutorLayout } from '../AITutor/AITutorLayout'; import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; import { aiRoadmapOptions, generateAIRoadmap } from '../../queries/ai-roadmap'; import { GenerateAIRoadmap } from './GenerateAIRoadmap'; -import { AIRoadmapContent } from './AIRoadmapContent'; +import { AIRoadmapContent, type RoadmapNodeDetails } from './AIRoadmapContent'; import { AIRoadmapChat } from './AIRoadmapChat'; import { AlertCircleIcon } from 'lucide-react'; +export type AIRoadmapChatActions = { + handleNodeClick: (node: RoadmapNodeDetails) => void; +}; + type AIRoadmapProps = { roadmapSlug?: string; }; @@ -28,6 +32,8 @@ export function AIRoadmap(props: AIRoadmapProps) { null, ); + const aiChatActionsRef = useRef(null); + // only fetch the guide if the guideSlug is provided // otherwise we are still generating the guide const { @@ -77,6 +83,13 @@ export function AIRoadmap(props: AIRoadmapProps) { const isLoading = isLoadingBySlug || isRegenerating; + const handleNodeClick = useCallback( + (node: RoadmapNodeDetails) => { + aiChatActionsRef.current?.handleNodeClick(node); + }, + [aiChatActionsRef], + ); + return ( )} {!roadmapSlug && !aiRoadmapError && ( @@ -115,6 +129,7 @@ export function AIRoadmap(props: AIRoadmapProps) { roadmapSlug={roadmapSlug} isRoadmapLoading={!aiRoadmap} onUpgrade={() => setShowUpgradeModal(true)} + aiChatActionsRef={aiChatActionsRef} /> ); diff --git a/src/components/AIRoadmap/AIRoadmapChat.tsx b/src/components/AIRoadmap/AIRoadmapChat.tsx index 557f89b7e..ca36feb2e 100644 --- a/src/components/AIRoadmap/AIRoadmapChat.tsx +++ b/src/components/AIRoadmap/AIRoadmapChat.tsx @@ -1,9 +1,11 @@ import { useCallback, useEffect, + useImperativeHandle, useLayoutEffect, useRef, useState, + type RefObject, } from 'react'; import { useChat, type ChatMessage } from '../../hooks/use-chat'; import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard'; @@ -29,15 +31,18 @@ import { billingDetailsOptions } from '../../queries/billing'; import { LoadingChip } from '../LoadingChip'; import { getTailwindScreenDimension } from '../../lib/is-mobile'; import { useToast } from '../../hooks/use-toast'; +import type { AIRoadmapChatActions } from './AIRoadmap'; +import type { RoadmapNodeDetails } from './AIRoadmapContent'; type AIRoadmapChatProps = { roadmapSlug?: string; isRoadmapLoading?: boolean; onUpgrade?: () => void; + aiChatActionsRef?: RefObject; }; export function AIRoadmapChat(props: AIRoadmapChatProps) { - const { roadmapSlug, isRoadmapLoading, onUpgrade } = props; + const { roadmapSlug, isRoadmapLoading, onUpgrade, aiChatActionsRef } = props; const toast = useToast(); const scrollareaRef = useRef(null); @@ -167,6 +172,16 @@ export function AIRoadmapChat(props: AIRoadmapChatProps) { } }, [isChatOpen, isMobile]); + useImperativeHandle(aiChatActionsRef, () => ({ + handleNodeClick: (node: RoadmapNodeDetails) => { + flushSync(() => { + setInputValue(`Explain what is ${node.nodeTitle} topic in detail.`); + }); + + inputRef.current?.focus(); + }, + })); + if (!isChatOpen) { return (
diff --git a/src/components/AIRoadmap/AIRoadmapContent.tsx b/src/components/AIRoadmap/AIRoadmapContent.tsx index 6d98447f2..ea89f91fb 100644 --- a/src/components/AIRoadmap/AIRoadmapContent.tsx +++ b/src/components/AIRoadmap/AIRoadmapContent.tsx @@ -1,16 +1,88 @@ import { cn } from '../../lib/classname'; import { AIRoadmapRegenerate } from './AIRoadmapRegenerate'; import { LoadingChip } from '../LoadingChip'; +import { type MouseEvent, useCallback } from 'react'; + +export type RoadmapNodeDetails = { + nodeId: string; + nodeType: string; + targetGroup?: SVGElement; + nodeTitle?: string; + parentTitle?: string; + parentId?: string; +}; + +export function getNodeDetails( + svgElement: SVGElement, +): RoadmapNodeDetails | null { + const targetGroup = (svgElement?.closest('g') as SVGElement) || {}; + + const nodeId = targetGroup?.dataset?.nodeId; + const nodeType = targetGroup?.dataset?.type; + const nodeTitle = targetGroup?.dataset?.title; + const parentTitle = targetGroup?.dataset?.parentTitle; + const parentId = targetGroup?.dataset?.parentId; + if (!nodeId || !nodeType) return null; + + return { nodeId, nodeType, targetGroup, nodeTitle, parentTitle, parentId }; +} + +export const allowedClickableNodeTypes = [ + 'topic', + 'subtopic', + 'button', + 'link-item', +]; type AIRoadmapContentProps = { isLoading?: boolean; svgHtml: string; onRegenerate?: (prompt?: string) => void; roadmapSlug?: string; + + onNodeClick?: (node: RoadmapNodeDetails) => void; }; export function AIRoadmapContent(props: AIRoadmapContentProps) { - const { isLoading, svgHtml, onRegenerate, roadmapSlug } = props; + const { isLoading, svgHtml, onRegenerate, roadmapSlug, onNodeClick } = props; + + const handleNodeClick = useCallback( + (e: MouseEvent) => { + if (isLoading) { + return; + } + + const target = e.target as SVGElement; + const { nodeId, nodeType, targetGroup, nodeTitle, parentTitle } = + getNodeDetails(target) || {}; + if ( + !nodeId || + !nodeType || + !allowedClickableNodeTypes.includes(nodeType) || + !nodeTitle + ) + return; + + if (nodeType === 'button' || nodeType === 'link-item') { + const link = targetGroup?.dataset?.link || ''; + const isExternalLink = link.startsWith('http'); + if (isExternalLink) { + window.open(link, '_blank'); + } else { + window.location.href = link; + } + return; + } + + onNodeClick?.({ + nodeId, + nodeType, + nodeTitle, + ...(nodeType === 'subtopic' && { parentTitle }), + }); + }, + [isLoading, onNodeClick], + ); return (
{isLoading && !svgHtml && (