1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-03 22:32:35 +02:00

Chat inside floating chat

This commit is contained in:
Kamran Ahmed
2025-06-09 21:45:31 +01:00
parent 1ae167e413
commit 0d229d60d4
2 changed files with 139 additions and 21 deletions

View File

@@ -1,11 +1,18 @@
import { Wand2 } from 'lucide-react'; import { Wand2, SendIcon, PauseCircleIcon } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState, useMemo, Fragment } from 'react';
import { roadmapJSONOptions } from '../../queries/roadmap'; import { roadmapJSONOptions } from '../../queries/roadmap';
import { queryClient } from '../../stores/query-client'; import { queryClient } from '../../stores/query-client';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard'; import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard';
import { lockBodyScroll } from '../../lib/dom'; import { lockBodyScroll } from '../../lib/dom';
import { useKeydown } from '../../hooks/use-keydown'; 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 = { type RoadmapChatProps = {
roadmapId: string; roadmapId: string;
@@ -14,12 +21,56 @@ type RoadmapChatProps = {
export function RoadmapFloatingChat(props: RoadmapChatProps) { export function RoadmapFloatingChat(props: RoadmapChatProps) {
const { roadmapId } = props; const { roadmapId } = props;
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const scrollareaRef = useRef<HTMLDivElement>(null);
const [inputValue, setInputValue] = useState('');
const { data: roadmapDetail, isLoading: isRoadmapDetailLoading } = useQuery( const { data: roadmapDetail, isLoading: isRoadmapDetailLoading } = useQuery(
roadmapJSONOptions(roadmapId), roadmapJSONOptions(roadmapId),
queryClient, 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(() => { useEffect(() => {
lockBodyScroll(isOpen); lockBodyScroll(isOpen);
}, [isOpen]); }, [isOpen]);
@@ -28,6 +79,31 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
setIsOpen(false); 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 ( return (
<> <>
{isOpen && ( {isOpen && (
@@ -39,45 +115,87 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
></div> ></div>
)} )}
<div <div className="animate-fade-slide-up fixed bottom-5 left-1/2 z-[99] max-h-[49vh] w-full max-w-[968px] -translate-x-1/4 transform flex-row gap-1.5 overflow-hidden px-4 transition-all duration-300 lg:flex">
className={
'animate-fade-slide-up fixed bottom-5 left-1/2 z-[99] max-h-[49vh] w-full max-w-[968px] -translate-x-1/4 transform flex-row gap-1.5 overflow-hidden px-4 transition-all duration-300 lg:flex'
}
>
{isOpen && ( {isOpen && (
<div className="flex h-full max-h-[49vh] w-full flex-col overflow-hidden rounded-lg bg-white shadow-lg"> <div className="flex h-full max-h-[49vh] w-full flex-col overflow-hidden rounded-lg bg-white shadow-lg">
{/* Messages area - scrollable */} {/* Messages area */}
<div className="flex-1 overflow-y-auto px-3 py-2"> <div
className="flex-1 overflow-y-auto px-3 py-2"
ref={scrollareaRef}
>
<div className="flex flex-col gap-2 text-sm"> <div className="flex flex-col gap-2 text-sm">
<RoadmapAIChatCard <RoadmapAIChatCard
role="assistant" role="assistant"
jsx={ jsx={<span>Hey, how can I help you?</span>}
<span className="relative top-[2px]"> isIntro
Hey, how can I help you?
</span>
}
/> />
{aiChatHistory.map(
(chat: RoadmapAIChatHistoryType, index: number) => (
<Fragment key={`chat-${index}`}>
<RoadmapAIChatCard {...chat} />
</Fragment>
),
)}
{isStreamingMessage && !streamedMessage && (
<RoadmapAIChatCard role="assistant" html="Thinking..." />
)}
{streamedMessage && (
<RoadmapAIChatCard role="assistant" jsx={streamedMessage} />
)}
</div> </div>
</div> </div>
{/* Input area - sticky at bottom */} {/* Input area */}
<div className="border-t border-gray-200"> <div className="relative flex items-center border-t border-gray-200 text-sm">
<input <input
type="text" type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
autoFocus autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (isStreamingMessage) {
return;
}
submitInput();
}
}}
placeholder="Ask me anything about this roadmap..." placeholder="Ask me anything about this roadmap..."
className="w-full resize-none p-3 outline-none" className="w-full resize-none p-3 outline-none"
/> />
<button
className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-zinc-500 hover:text-black disabled:opacity-50"
disabled={isRoadmapDetailLoading}
onClick={() => {
if (isStreamingMessage) {
handleAbort();
return;
}
submitInput();
}}
>
{isStreamingMessage ? (
<PauseCircleIcon className="h-4 w-4" />
) : (
<SendIcon className="h-4 w-4" />
)}
</button>
</div> </div>
</div> </div>
)} )}
{!isOpen && ( {!isOpen && (
<button <button
className={ className="relative mx-auto flex cursor-text items-center justify-center gap-2 rounded-full bg-stone-900 py-2.5 pr-8 pl-6 text-center text-white shadow-2xl transition-all duration-300 hover:scale-101 hover:bg-stone-800"
'relative mx-auto flex cursor-text items-center justify-center gap-2 rounded-full bg-stone-900 py-2.5 pr-8 pl-6 text-center text-black text-white shadow-2xl transition-all duration-300 hover:scale-101 hover:bg-stone-800' onClick={() => {
} setIsOpen(true);
onClick={() => setIsOpen(true)} setTimeout(() => scrollToBottom(), 0);
}}
> >
<Wand2 className="h-4 w-4 text-yellow-400" /> <Wand2 className="h-4 w-4 text-yellow-400" />
<span className="mr-1 text-sm font-semibold text-yellow-400"> <span className="mr-1 text-sm font-semibold text-yellow-400">

View File

@@ -86,7 +86,6 @@ export function useRoadmapAIChat(options: Options) {
abortController?: AbortController, abortController?: AbortController,
) => { ) => {
try { try {
setIsStreamingMessage(true);
const response = await fetch( const response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-chat-roadmap`, `${import.meta.env.PUBLIC_API_URL}/v1-chat-roadmap`,
{ {
@@ -174,6 +173,7 @@ export function useRoadmapAIChat(options: Options) {
{ role: 'user' as AllowedAIChatRole, json, html }, { role: 'user' as AllowedAIChatRole, json, html },
]; ];
setIsStreamingMessage(true);
flushSync(() => setAiChatHistory(newMessages)); flushSync(() => setAiChatHistory(newMessages));
scrollToBottom(); scrollToBottom();
completeAITutorChat(newMessages, abortControllerRef.current); completeAITutorChat(newMessages, abortControllerRef.current);