feat: responsiveness of ai chat

feat/chat
Arik Chakma 3 weeks ago
parent 1323975b28
commit 27a08dba0f
  1. 24
      src/components/GenerateCourse/AICourseContent.tsx
  2. 42
      src/components/GenerateCourse/AICourseLesson.tsx
  3. 222
      src/components/GenerateCourse/AICourseLessonChat.tsx

@ -6,8 +6,11 @@ import {
Menu, Menu,
X, X,
Map, Map,
MessageCircle,
MessageCircleOffIcon,
MessageCircleIcon,
} from 'lucide-react'; } from 'lucide-react';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { type AiCourse } from '../../lib/ai'; import { type AiCourse } from '../../lib/ai';
import { cn } from '../../lib/classname'; import { cn } from '../../lib/classname';
import { useIsPaidUser } from '../../queries/billing'; import { useIsPaidUser } from '../../queries/billing';
@ -208,6 +211,12 @@ export function AICourseContent(props: AICourseContentProps) {
const isViewingLesson = viewMode === 'module'; const isViewingLesson = viewMode === 'module';
useEffect(() => {
if (window && window?.innerWidth < 1024 && isAIChatsOpen) {
setIsAIChatsOpen(false);
}
}, []);
return ( return (
<section className="flex h-screen flex-grow flex-col overflow-hidden bg-gray-50"> <section className="flex h-screen flex-grow flex-col overflow-hidden bg-gray-50">
{modals} {modals}
@ -236,6 +245,19 @@ export function AICourseContent(props: AICourseContentProps) {
/> />
</div> </div>
{viewMode === 'module' && (
<button
onClick={() => setIsAIChatsOpen(!isAIChatsOpen)}
className="ml-1.5 flex items-center justify-center text-gray-400 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900 lg:hidden"
>
{isAIChatsOpen ? (
<MessageCircleOffIcon size={17} strokeWidth={3} />
) : (
<MessageCircleIcon size={17} strokeWidth={3} />
)}
</button>
)}
<button <button
onClick={() => setSidebarOpen(!sidebarOpen)} onClick={() => setSidebarOpen(!sidebarOpen)}
className="flex items-center justify-center text-gray-400 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900 lg:hidden" className="flex items-center justify-center text-gray-400 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900 lg:hidden"

@ -243,6 +243,17 @@ export function AICourseLesson(props: AICourseLessonProps) {
{!isGenerating && !isLoading && ( {!isGenerating && !isLoading && (
<div className="absolute right-6 top-6 flex items-center justify-between gap-2"> <div className="absolute right-6 top-6 flex items-center justify-between gap-2">
<button
onClick={() => setIsAIChatsOpen(!isAIChatsOpen)}
className="rounded-full p-1 text-gray-400 hover:text-black max-lg:hidden"
>
{!isAIChatsOpen ? (
<MessageCircleIcon className="size-4 stroke-[2.5]" />
) : (
<MessageCircleOffIcon className="size-4 stroke-[2.5]" />
)}
</button>
<RegenerateLesson <RegenerateLesson
onRegenerateLesson={(prompt) => { onRegenerateLesson={(prompt) => {
generateAiCourseContent(true, prompt); generateAiCourseContent(true, prompt);
@ -283,17 +294,6 @@ export function AICourseLesson(props: AICourseLessonProps) {
</> </>
)} )}
</button> </button>
<button
onClick={() => setIsAIChatsOpen(!isAIChatsOpen)}
className="rounded-full p-1 text-gray-400 hover:text-black max-lg:hidden"
>
{!isAIChatsOpen ? (
<MessageCircleIcon className="size-4 stroke-[2.5]" />
) : (
<MessageCircleOffIcon className="size-4 stroke-[2.5]" />
)}
</button>
</div> </div>
)} )}
</div> </div>
@ -419,16 +419,16 @@ export function AICourseLesson(props: AICourseLessonProps) {
</div> </div>
</div> </div>
{isAIChatsOpen && ( <AICourseLessonChat
<AICourseLessonChat courseSlug={courseSlug}
courseSlug={courseSlug} moduleTitle={currentModuleTitle}
moduleTitle={currentModuleTitle} lessonTitle={currentLessonTitle}
lessonTitle={currentLessonTitle} onUpgradeClick={onUpgrade}
onUpgradeClick={onUpgrade} isDisabled={isGenerating || isLoading || isTogglingDone}
isDisabled={isGenerating || isLoading || isTogglingDone} onClose={() => setIsAIChatsOpen(false)}
onClose={() => setIsAIChatsOpen(false)} isAIChatsOpen={isAIChatsOpen}
/> setIsAIChatsOpen={setIsAIChatsOpen}
)} />
</div> </div>
); );
} }

@ -47,6 +47,9 @@ type AICourseLessonChatProps = {
isDisabled?: boolean; isDisabled?: boolean;
onClose: () => void; onClose: () => void;
isAIChatsOpen: boolean;
setIsAIChatsOpen: (isAIChatsOpen: boolean) => void;
}; };
export function AICourseLessonChat(props: AICourseLessonChatProps) { export function AICourseLessonChat(props: AICourseLessonChatProps) {
@ -57,10 +60,12 @@ export function AICourseLessonChat(props: AICourseLessonChatProps) {
onUpgradeClick, onUpgradeClick,
isDisabled, isDisabled,
onClose, onClose,
isAIChatsOpen,
setIsAIChatsOpen,
} = props; } = props;
const toast = useToast(); const toast = useToast();
const containerRef = useRef<HTMLDivElement | null>(null);
const scrollareaRef = useRef<HTMLDivElement | null>(null); const scrollareaRef = useRef<HTMLDivElement | null>(null);
const [courseAIChatHistory, setCourseAIChatHistory] = useState< const [courseAIChatHistory, setCourseAIChatHistory] = useState<
@ -203,124 +208,127 @@ export function AICourseLessonChat(props: AICourseLessonChatProps) {
scrollToBottom(); scrollToBottom();
}, []); }, []);
useOutsideClick(containerRef, () => {
onClose();
});
return ( return (
<div className="relative col-span-2 h-full border-l border-gray-200 max-lg:fixed max-lg:inset-y-0 max-lg:right-0 max-lg:z-10 max-lg:w-[420px] max-lg:border-none"> <>
<div className="fixed inset-0 z-10 bg-black/50 lg:hidden" /> {isAIChatsOpen && (
<div
className="fixed inset-0 z-10 bg-black/50 lg:hidden"
onClick={onClose}
/>
)}
<div <div
className="absolute inset-0 z-20 flex w-full flex-col overflow-hidden bg-white" className="relative col-span-2 h-full border-l border-gray-200 transition-all data-[state=closed]:hidden max-lg:fixed max-lg:inset-y-0 max-lg:right-0 max-lg:z-10 max-lg:w-[420px] max-lg:border-none max-lg:data-[state=closed]:translate-x-full max-lg:data-[state=open]:translate-x-0"
ref={containerRef} data-state={isAIChatsOpen ? 'open' : 'closed'}
> >
<button <div className="absolute inset-y-0 right-0 z-20 flex w-full flex-col overflow-hidden bg-white">
onClick={onClose} <button
className="absolute right-2 top-2 hidden rounded-full p-1 text-gray-400 hover:text-black max-lg:block" onClick={onClose}
> className="absolute right-2 top-2 hidden rounded-full p-1 text-gray-400 hover:text-black max-lg:block"
<XIcon className="size-4 stroke-[2.5]" /> >
</button> <XIcon className="size-4 stroke-[2.5]" />
</button>
<div className="flex items-center justify-between gap-2 border-b border-gray-200 px-4 py-2 text-sm"> <div className="flex items-center justify-between gap-2 border-b border-gray-200 px-4 py-2 text-sm">
<h4 className="text-base font-medium">Course AI</h4> <h4 className="text-base font-medium">Course AI</h4>
</div> </div>
<div <div
className="scrollbar-thumb-gray-300 scrollbar-track-transparent scrollbar-thin relative grow overflow-y-auto" className="scrollbar-thumb-gray-300 scrollbar-track-transparent scrollbar-thin relative grow overflow-y-auto"
ref={scrollareaRef} ref={scrollareaRef}
> >
<div className="absolute inset-0 flex flex-col"> <div className="absolute inset-0 flex flex-col">
<div className="flex grow flex-col justify-end"> <div className="flex grow flex-col justify-end">
<div className="flex flex-col justify-end gap-2 px-3 py-2"> <div className="flex flex-col justify-end gap-2 px-3 py-2">
{courseAIChatHistory.map((chat, index) => { {courseAIChatHistory.map((chat, index) => {
return ( return (
<> <>
<AIChatCard <AIChatCard
key={`chat-${index}`} key={`chat-${index}`}
role={chat.role} role={chat.role}
content={chat.content} content={chat.content}
html={chat.html} html={chat.html}
/> />
{chat.isDefault && ( {chat.isDefault && (
<div className="mb-1 mt-0.5"> <div className="mb-1 mt-0.5">
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{capabilities.map((capability, index) => ( {capabilities.map((capability, index) => (
<CapabilityCard <CapabilityCard
key={`capability-${index}`} key={`capability-${index}`}
{...capability} {...capability}
/> />
))} ))}
</div>
</div> </div>
</div> )}
)} </>
</> );
); })}
})}
{isStreamingMessage && !streamedMessage && (
{isStreamingMessage && !streamedMessage && ( <AIChatCard role="assistant" content="Thinking..." />
<AIChatCard role="assistant" content="Thinking..." /> )}
)}
{streamedMessage && (
{streamedMessage && ( <AIChatCard role="assistant" content={streamedMessage} />
<AIChatCard role="assistant" content={streamedMessage} /> )}
)} </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<form <form
className="relative flex items-start border-t border-gray-200 text-sm" className="relative flex items-start border-t border-gray-200 text-sm"
onSubmit={handleChatSubmit} onSubmit={handleChatSubmit}
>
{isLimitExceeded && (
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black text-white">
<LockIcon
className="size-4 cursor-not-allowed"
strokeWidth={2.5}
/>
<p className="cursor-not-allowed">
Limit reached for today
{isPaidUser ? '. Please wait until tomorrow.' : ''}
</p>
{!isPaidUser && (
<button
onClick={() => {
onUpgradeClick();
}}
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
>
Upgrade for more
</button>
)}
</div>
)}
<TextareaAutosize
className={cn(
'h-full min-h-[41px] grow resize-none bg-transparent px-4 py-2 focus:outline-none',
isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-auto',
)}
placeholder="Ask AI anything about the lesson..."
value={message}
onChange={(e) => setMessage(e.target.value)}
autoFocus={true}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
handleChatSubmit(e as unknown as FormEvent<HTMLFormElement>);
}
}}
/>
<button
type="submit"
disabled={isDisabled || isStreamingMessage || isLimitExceeded}
className="flex aspect-square size-[41px] items-center justify-center text-zinc-500 hover:text-black disabled:cursor-not-allowed disabled:opacity-50"
> >
<Send className="size-4 stroke-[2.5]" /> {isLimitExceeded && (
</button> <div className="absolute inset-0 flex items-center justify-center gap-2 bg-black text-white">
</form> <LockIcon
className="size-4 cursor-not-allowed"
strokeWidth={2.5}
/>
<p className="cursor-not-allowed">
Limit reached for today
{isPaidUser ? '. Please wait until tomorrow.' : ''}
</p>
{!isPaidUser && (
<button
onClick={() => {
onUpgradeClick();
}}
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
>
Upgrade for more
</button>
)}
</div>
)}
<TextareaAutosize
className={cn(
'h-full min-h-[41px] grow resize-none bg-transparent px-4 py-2 focus:outline-none',
isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-auto',
)}
placeholder="Ask AI anything about the lesson..."
value={message}
onChange={(e) => setMessage(e.target.value)}
autoFocus={true}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
handleChatSubmit(e as unknown as FormEvent<HTMLFormElement>);
}
}}
/>
<button
type="submit"
disabled={isDisabled || isStreamingMessage || isLimitExceeded}
className="flex aspect-square size-[41px] items-center justify-center text-zinc-500 hover:text-black disabled:cursor-not-allowed disabled:opacity-50"
>
<Send className="size-4 stroke-[2.5]" />
</button>
</form>
</div>
</div> </div>
</div> </>
); );
} }

Loading…
Cancel
Save