feat: histories in global chat and roadmap chat (#8775)

* feat: ai chat history

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip: skeleton loading

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* fix: chat history

* wip

* wip

* fix: responsiveness

* wip

* wip

* Chat history UI

* Update chat history

* wip

* Update chat history

* Update chat history

* Fix ai chat not working

* Update

* wip

* feat: show chat history always

* feat: upgrade to pro

* wip

* Update history design

* UI design improvement for empty sidebar

* feat: chat history title

* Fix, delete chat throwing error

* Plus icon when chat is closed

* fix: action z-index

* Improve skeleton and logged out user workflow

* Chat history improvements

* Add plus for chat icons

---------

Co-authored-by: Arik Chakma <arikchangma@gmail.com>
This commit is contained in:
Kamran AhmedandArik Chakma authored and GitHub committed 2025-06-13 01:19:01 +01:00
1 parent 7d91696c85
commit 0f99964bd6
32 files changed
+1995 -325

No files matched your search

+157 -90
View File
@@ -7,14 +7,7 @@ import {
SendIcon,
TrashIcon,
} from 'lucide-react';
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import AutogrowTextarea from 'react-textarea-autosize';
import { QuickHelpPrompts } from './QuickHelpPrompts';
@@ -25,7 +18,6 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client';
import { billingDetailsOptions } from '../../queries/billing';
import { useToast } from '../../hooks/use-toast';
import { readStream } from '../../lib/ai';
import { markdownToHtml } from '../../lib/markdown';
import { ChatHistory } from './ChatHistory';
import { PersonalizedResponseForm } from './PersonalizedResponseForm';
@@ -38,31 +30,47 @@ import {
type MessagePartRenderer,
} from '../../lib/render-chat-message';
import { RoadmapRecommendations } from '../RoadmapAIChat/RoadmapRecommendations';
import type { RoadmapAIChatHistoryType } from '../RoadmapAIChat/RoadmapAIChat';
import { AIChatCourse } from './AIChatCouse';
import { getTailwindScreenDimension } from '../../lib/is-mobile';
import type { TailwindScreenDimensions } from '../../lib/is-mobile';
import { showLoginPopup } from '../../lib/popup';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { readChatStream } from '../../lib/chat';
import { chatHistoryOptions } from '../../queries/chat-history';
import { cn } from '../../lib/classname';
import type { RoadmapAIChatHistoryType } from '../../hooks/use-roadmap-ai-chat';
export const aiChatRenderer: Record<string, MessagePartRenderer> = {
'roadmap-recommendations': (options) => {
return <RoadmapRecommendations {...options} />;
},
'generate-course': (options) => {
return <AIChatCourse {...options} />;
},
};
type AIChatProps = {
messages?: RoadmapAIChatHistoryType[];
chatHistoryId?: string;
setChatHistoryId?: (chatHistoryId: string) => void;
onUpgrade?: () => void;
};
export function AIChat(props: AIChatProps) {
const {
messages: defaultMessages,
chatHistoryId: defaultChatHistoryId,
setChatHistoryId: setDefaultChatHistoryId,
onUpgrade,
} = props;
export function AIChat() {
const toast = useToast();
const [deviceType, setDeviceType] = useState<TailwindScreenDimensions>();
useLayoutEffect(() => {
setDeviceType(getTailwindScreenDimension());
}, []);
const [message, setMessage] = useState('');
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
const [streamedMessage, setStreamedMessage] =
useState<React.ReactNode | null>(null);
const [aiChatHistory, setAiChatHistory] = useState<
RoadmapAIChatHistoryType[]
>([]);
>(defaultMessages ?? []);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [isPersonalizedResponseFormOpen, setIsPersonalizedResponseFormOpen] =
useState(false);
const [isUploadResumeModalOpen, setIsUploadResumeModalOpen] = useState(false);
@@ -89,6 +97,34 @@ export function AIChat() {
userResumeOptions(),
queryClient,
);
const { mutate: deleteChatMessage, isPending: isDeletingChatMessage } =
useMutation(
{
mutationFn: (messages: RoadmapAIChatHistoryType[]) => {
if (!defaultChatHistoryId) {
return Promise.resolve({
status: 200,
message: 'Chat history not found',
});
}
return httpPost(`/v1-delete-chat-message/${defaultChatHistoryId}`, {
messages,
});
},
onSuccess: () => {
textareaMessageRef.current?.focus();
queryClient.invalidateQueries(
chatHistoryOptions(defaultChatHistoryId),
);
},
onError: (error) => {
toast.error(error?.message || 'Failed to delete message');
},
},
queryClient,
);
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
const isPaidUser = userBillingDetails?.status === 'active';
@@ -101,7 +137,7 @@ export function AIChat() {
if (isLimitExceeded) {
if (!isPaidUser) {
setShowUpgradeModal(true);
onUpgrade?.();
}
toast.error('Limit reached for today. Please wait until tomorrow.');
@@ -136,29 +172,39 @@ export function AIChat() {
completeAIChat(newMessages);
};
const scrollToBottom = useCallback(() => {
const canScrollToBottom = useCallback(() => {
const scrollableContainer = scrollableContainerRef?.current;
if (!scrollableContainer) {
return;
return false;
}
scrollableContainer.scrollTo({
top: scrollableContainer.scrollHeight,
behavior: 'smooth',
});
}, [scrollableContainerRef]);
const paddingBottom = parseInt(
getComputedStyle(scrollableContainer).paddingBottom,
);
const renderer: Record<string, MessagePartRenderer> = useMemo(() => {
return {
'roadmap-recommendations': (options) => {
return <RoadmapRecommendations {...options} />;
},
'generate-course': (options) => {
return <AIChatCourse {...options} />;
},
};
const distanceFromBottom =
scrollableContainer.scrollHeight -
(scrollableContainer.scrollTop + scrollableContainer.clientHeight) -
paddingBottom;
return distanceFromBottom > -(paddingBottom - 80);
}, []);
const scrollToBottom = useCallback(
(behavior: 'instant' | 'smooth' = 'smooth') => {
const scrollableContainer = scrollableContainerRef?.current;
if (!scrollableContainer) {
return;
}
scrollableContainer.scrollTo({
top: scrollableContainer.scrollHeight,
behavior: behavior === 'instant' ? 'instant' : 'smooth',
});
},
[scrollableContainerRef],
);
const completeAIChat = async (
messages: RoadmapAIChatHistoryType[],
force: boolean = false,
@@ -172,6 +218,7 @@ export function AIChat() {
},
credentials: 'include',
body: JSON.stringify({
chatHistoryId: defaultChatHistoryId,
messages: messages.slice(-10),
force,
}),
@@ -190,28 +237,26 @@ export function AIChat() {
}
}
const reader = response.body?.getReader();
if (!reader) {
const stream = response.body;
if (!stream) {
setIsStreamingMessage(false);
toast.error('Something went wrong');
return;
}
await readStream(reader, {
onStream: async (content) => {
const jsx = await renderMessage(content, renderer, {
await readChatStream(stream, {
onMessage: async (content) => {
const jsx = await renderMessage(content, aiChatRenderer, {
isLoading: true,
});
flushSync(() => {
setStreamedMessage(jsx);
});
scrollToBottom();
setShowScrollToBottomButton(canScrollToBottom());
},
onStreamEnd: async (content) => {
const jsx = await renderMessage(content, renderer, {
onMessageEnd: async (content) => {
const jsx = await renderMessage(content, aiChatRenderer, {
isLoading: false,
});
@@ -231,7 +276,20 @@ export function AIChat() {
});
queryClient.invalidateQueries(getAiCourseLimitOptions());
scrollToBottom();
queryClient.invalidateQueries({
predicate: (query) => {
return query.queryKey[0] === 'list-chat-history';
},
});
},
onDetails: (details) => {
const detailsJson = JSON.parse(details);
const chatHistoryId = detailsJson?.chatHistoryId;
if (!chatHistoryId) {
return;
}
setDefaultChatHistoryId?.(chatHistoryId);
},
});
@@ -272,17 +330,7 @@ export function AIChat() {
}
timeoutId = setTimeout(() => {
const paddingBottom = parseInt(
getComputedStyle(scrollableContainer).paddingBottom,
);
const distanceFromBottom =
scrollableContainer.scrollHeight -
// scroll from the top + the container height
(scrollableContainer.scrollTop + scrollableContainer.clientHeight) -
paddingBottom;
setShowScrollToBottomButton(distanceFromBottom > -(paddingBottom - 80));
setShowScrollToBottomButton(canScrollToBottom());
}, 100);
};
@@ -303,7 +351,7 @@ export function AIChat() {
(index: number) => {
if (isLimitExceeded) {
if (!isPaidUser) {
setShowUpgradeModal(true);
onUpgrade?.();
}
toast.error('Limit reached for today. Please wait until tomorrow.');
@@ -325,6 +373,7 @@ export function AIChat() {
(index: number) => {
const filteredChatHistory = aiChatHistory.filter((_, i) => i !== index);
setAiChatHistory(filteredChatHistory);
deleteChatMessage(filteredChatHistory);
},
[aiChatHistory],
);
@@ -337,29 +386,40 @@ export function AIChat() {
isUserPersonaLoading ||
isUserResumeLoading;
useEffect(() => {
scrollToBottom('instant');
}, []);
const shouldShowUpgradeBanner = !isPaidUser && aiChatHistory.length > 0;
return (
<div
className="ai-chat relative flex min-h-screen w-full flex-col gap-2 overflow-y-auto bg-gray-100 pb-55"
ref={scrollableContainerRef}
>
<div className="relative mx-auto w-full max-w-3xl grow px-4">
{shouldShowQuickHelpPrompts && (
<QuickHelpPrompts
onQuestionClick={(question) => {
textareaMessageRef.current?.focus();
setMessage(question);
}}
/>
)}
{!shouldShowQuickHelpPrompts && (
<ChatHistory
chatHistory={aiChatHistory}
isStreamingMessage={isStreamingMessage}
streamedMessage={streamedMessage}
onDelete={handleDelete}
onRegenerate={handleRegenerate}
/>
<div className="ai-chat relative flex grow flex-col gap-2 bg-gray-100">
<div
className={cn(
'scrollbar-none absolute inset-0 overflow-y-auto pb-55',
shouldShowUpgradeBanner ? 'pb-60' : 'pb-55',
)}
ref={scrollableContainerRef}
>
<div className="relative mx-auto w-full max-w-3xl grow px-4">
{shouldShowQuickHelpPrompts && (
<QuickHelpPrompts
onQuestionClick={(question) => {
textareaMessageRef.current?.focus();
setMessage(question);
}}
/>
)}
{!shouldShowQuickHelpPrompts && (
<ChatHistory
chatHistory={aiChatHistory}
isStreamingMessage={isStreamingMessage}
streamedMessage={streamedMessage}
onDelete={handleDelete}
onRegenerate={handleRegenerate}
/>
)}
</div>
</div>
{isPersonalizedResponseFormOpen && (
@@ -378,12 +438,8 @@ export function AIChat() {
/>
)}
{showUpgradeModal && (
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
)}
<div
className="pointer-events-none fixed right-0 bottom-0 left-0 mx-auto w-full max-w-3xl px-4 lg:left-[var(--ai-sidebar-width)]"
className="pointer-events-none absolute right-0 bottom-0 left-0 mx-auto w-full max-w-3xl px-4"
ref={chatContainerRef}
>
<div className="mb-2 flex items-center justify-between gap-2">
@@ -392,6 +448,11 @@ export function AIChat() {
icon={PersonStandingIcon}
label="Personalize"
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setIsPersonalizedResponseFormOpen(true);
}}
/>
@@ -399,6 +460,11 @@ export function AIChat() {
icon={FileUpIcon}
label={isUploading ? 'Processing...' : 'Upload Resume'}
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setIsUploadResumeModalOpen(true);
}}
isLoading={isUploading}
@@ -413,12 +479,13 @@ export function AIChat() {
onClick={scrollToBottom}
/>
)}
{aiChatHistory.length > 0 && (
{aiChatHistory.length > 0 && !isPaidUser && (
<QuickActionButton
icon={TrashIcon}
label="Clear Chat"
onClick={() => {
setAiChatHistory([]);
deleteChatMessage([]);
}}
/>
)}
@@ -470,7 +537,7 @@ export function AIChat() {
<button
type="button"
onClick={() => {
setShowUpgradeModal(true);
onUpgrade?.();
}}
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
>
+1 -1
View File
@@ -8,8 +8,8 @@ import {
RotateCwIcon,
} from 'lucide-react';
import { useCopyText } from '../../hooks/use-copy-text';
import type { RoadmapAIChatHistoryType } from '../RoadmapAIChat/RoadmapAIChat';
import { Tooltip } from '../Tooltip';
import type { RoadmapAIChatHistoryType } from '../../hooks/use-roadmap-ai-chat';
type ChatHistoryProps = {
chatHistory: RoadmapAIChatHistoryType[];
@@ -57,6 +57,7 @@ export function QuickHelpPrompts(props: QuickHelpPromptsProps) {
<div className="mt-6 flex flex-wrap items-center gap-2">
{quickActions.map((action, index) => (
<button
key={action.label}
className={cn(
'pointer-events-auto flex shrink-0 cursor-pointer items-center gap-2 rounded-lg border bg-white px-2 py-1.5 text-sm hover:bg-gray-100 hover:text-black',
selectedActionIndex === index
@@ -73,6 +74,7 @@ export function QuickHelpPrompts(props: QuickHelpPromptsProps) {
<div className="mt-6 divide-y divide-gray-200">
{selectedAction.questions.map((question) => (
<button
type="button"
key={question}
className="block w-full cursor-pointer p-2 text-left text-sm text-gray-500 hover:bg-gray-100 hover:text-black"
onClick={() => onQuestionClick(question)}
@@ -0,0 +1,171 @@
import { useQuery } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client';
import { chatHistoryOptions } from '../../queries/chat-history';
import { AIChat, aiChatRenderer } from '../AIChat/AIChat';
import { Loader2Icon } from 'lucide-react';
import { useEffect, useState, useCallback } from 'react';
import { AIChatLayout } from './AIChatLayout';
import { ListChatHistory } from './ListChatHistory';
import { billingDetailsOptions } from '../../queries/billing';
import { ChatHistoryError } from './ChatHistoryError';
import { useClientMount } from '../../hooks/use-client-mount';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
type AIChatHistoryProps = {
chatHistoryId?: string;
};
export function AIChatHistory(props: AIChatHistoryProps) {
const { chatHistoryId: defaultChatHistoryId } = props;
const isClientMounted = useClientMount();
const [keyTrigger, setKeyTrigger] = useState(0);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [isChatHistoryLoading, setIsChatHistoryLoading] = useState(true);
const [chatHistoryId, setChatHistoryId] = useState<string | undefined>(
defaultChatHistoryId || undefined,
);
const { data, error: chatHistoryError } = useQuery(
chatHistoryOptions(chatHistoryId, aiChatRenderer),
queryClient,
);
const {
data: userBillingDetails,
isLoading: isBillingDetailsLoading,
error: billingDetailsError,
} = useQuery(billingDetailsOptions(), queryClient);
const handleChatHistoryClick = useCallback(
(nextChatHistoryId: string | null) => {
setKeyTrigger((key) => key + 1);
if (nextChatHistoryId === null) {
setChatHistoryId(undefined);
window.history.replaceState(null, '', '/ai/chat');
return;
}
// show loader only if the chat history hasn't been fetched before (avoids UI flash)
const hasAlreadyFetched = queryClient.getQueryData(
chatHistoryOptions(nextChatHistoryId).queryKey,
);
if (!hasAlreadyFetched) {
setIsChatHistoryLoading(true);
}
setChatHistoryId(nextChatHistoryId);
window.history.replaceState(null, '', `/ai/chat/${nextChatHistoryId}`);
},
[],
);
const handleDelete = useCallback(
(deletedChatHistoryId: string) => {
if (deletedChatHistoryId !== chatHistoryId) {
return;
}
setChatHistoryId(undefined);
window.history.replaceState(null, '', '/ai/chat');
setKeyTrigger((key) => key + 1);
},
[chatHistoryId],
);
const isPaidUser = userBillingDetails?.status === 'active';
const hasError = chatHistoryError || billingDetailsError;
const showLoader = isChatHistoryLoading && !hasError;
const showError = !isChatHistoryLoading && Boolean(hasError);
useEffect(() => {
if (!chatHistoryId) {
setIsChatHistoryLoading(false);
return;
}
if (!data) {
return;
}
setIsChatHistoryLoading(false);
}, [data, chatHistoryId]);
useEffect(() => {
if (!hasError) {
return;
}
setIsChatHistoryLoading(false);
}, [hasError]);
if (!isClientMounted || isBillingDetailsLoading) {
return (
<AIChatLayout>
<div className="relative flex grow">
<div className="absolute inset-0 z-20 flex items-center justify-center">
<Loader2Icon className="h-8 w-8 animate-spin stroke-[2.5] text-gray-400/80" />
</div>
</div>
</AIChatLayout>
);
}
return (
<AIChatLayout>
<div className="relative flex grow">
<ListChatHistory
activeChatHistoryId={chatHistoryId}
onChatHistoryClick={handleChatHistoryClick}
onDelete={handleDelete}
isPaidUser={isPaidUser}
onUpgrade={() => {
setShowUpgradeModal(true);
}}
/>
<div className="relative flex grow">
{showLoader && (
<div className="absolute inset-0 z-20 flex items-center justify-center">
<Loader2Icon className="h-8 w-8 animate-spin stroke-[2.5] text-gray-400/80" />
</div>
)}
{showError && (
<div className="absolute inset-0 z-20 flex items-center justify-center">
<ChatHistoryError error={hasError} className="mt-0" />
</div>
)}
{!showLoader && !showError && (
<AIChat
key={keyTrigger}
messages={data?.messages}
chatHistoryId={chatHistoryId}
setChatHistoryId={(id) => {
setChatHistoryId(id);
window.history.replaceState(null, '', `/ai/chat/${id}`);
queryClient.invalidateQueries({
predicate: (query) => {
return query.queryKey[0] === 'list-chat-history';
},
});
}}
onUpgrade={() => {
setShowUpgradeModal(true);
}}
/>
)}
</div>
</div>
{showUpgradeModal && (
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
)}
</AIChatLayout>
);
}
@@ -0,0 +1,22 @@
import { AITutorLayout } from '../AITutor/AITutorLayout';
import { CheckSubscriptionVerification } from '../Billing/CheckSubscriptionVerification';
import { Loader2Icon } from 'lucide-react';
type AIChatLayoutProps = {
children: React.ReactNode;
};
export function AIChatLayout(props: AIChatLayoutProps) {
const { children } = props;
return (
<AITutorLayout
activeTab="chat"
wrapperClassName="flex-row p-0 lg:p-0 overflow-hidden"
containerClassName="h-[calc(100vh-49px)] overflow-hidden"
>
{children}
<CheckSubscriptionVerification />
</AITutorLayout>
);
}
@@ -0,0 +1,116 @@
import { EllipsisVerticalIcon, Loader2Icon, Trash2Icon } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../DropdownMenu';
import { queryClient } from '../../stores/query-client';
import { useMutation } from '@tanstack/react-query';
import { httpDelete } from '../../lib/query-http';
import { listChatHistoryOptions } from '../../queries/chat-history';
import { useState } from 'react';
import { useToast } from '../../hooks/use-toast';
type ChatHistoryActionProps = {
chatHistoryId: string;
onDelete?: () => void;
};
export function ChatHistoryAction(props: ChatHistoryActionProps) {
const { chatHistoryId, onDelete } = props;
const toast = useToast();
const [isOpen, setIsOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const { mutate: deleteChatHistory, isPending: isDeletingLoading } =
useMutation(
{
mutationFn: (chatHistoryId: string) => {
return httpDelete(`/v1-delete-chat/${chatHistoryId}`);
},
onSettled: () => {
return queryClient.invalidateQueries({
predicate: (query) => {
return query.queryKey[0] === 'list-chat-history';
},
});
},
onSuccess: () => {
toast.success('Chat history deleted');
setIsOpen(false);
onDelete?.();
},
onError: (error) => {
toast.error(error?.message || 'Failed to delete chat history');
},
},
queryClient,
);
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger className="rounded-lg p-2 opacity-0 group-hover/item:opacity-100 hover:bg-gray-100 focus:outline-none data-[state=open]:bg-gray-100 data-[state=open]:opacity-100">
<EllipsisVerticalIcon className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-[9999]">
{!isDeleting && (
<DropdownMenuItem
className="cursor-pointer text-red-500 focus:bg-red-50 focus:text-red-500"
onSelect={(e) => {
e.preventDefault();
setIsDeleting(true);
}}
disabled={isDeletingLoading}
>
{isDeletingLoading ? (
<>
<Loader2Icon className="h-4 w-4 animate-spin" />
Deleting...
</>
) : (
<>
<Trash2Icon className="h-4 w-4" />
Delete
</>
)}
</DropdownMenuItem>
)}
{isDeleting && (
<DropdownMenuItem
asChild
className="focus:bg-transparent"
onSelect={(e) => {
e.preventDefault();
}}
disabled={isDeletingLoading}
>
<div className="flex w-full items-center justify-between gap-1.5">
Are you sure?
<div className="flex items-center gap-2">
<button
onClick={() => {
deleteChatHistory(chatHistoryId);
setIsDeleting(false);
}}
className="cursor-pointer text-red-500 underline hover:text-red-800"
disabled={isDeletingLoading}
>
Yes
</button>
<button
onClick={() => setIsDeleting(false)}
className="cursor-pointer text-red-500 underline hover:text-red-800"
disabled={isDeletingLoading}
>
No
</button>
</div>
</div>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,28 @@
import { AlertCircleIcon } from 'lucide-react';
import { cn } from '../../lib/classname';
type ChatHistoryErrorProps = {
error: Error | null;
className?: string;
};
export function ChatHistoryError(props: ChatHistoryErrorProps) {
const { error, className } = props;
return (
<div
className={cn(
'mt-10 flex max-w-md flex-col items-center justify-center text-center',
className,
)}
>
<AlertCircleIcon className="h-8 w-8 text-red-500" />
<h3 className="mt-4 text-sm font-medium text-gray-900">
Something went wrong
</h3>
<p className="mt-0.5 text-xs text-balance text-gray-500">
{error?.message}
</p>
</div>
);
}
@@ -0,0 +1,42 @@
import type { ChatHistoryWithoutMessages } from '../../queries/chat-history';
import { ChatHistoryItem } from './ChatHistoryItem';
type ChatHistoryGroupProps = {
title: string;
histories: ChatHistoryWithoutMessages[];
activeChatHistoryId?: string;
onChatHistoryClick: (id: string) => void;
onDelete: (id: string) => void;
};
export function ChatHistoryGroup(props: ChatHistoryGroupProps) {
const {
title,
histories,
activeChatHistoryId,
onChatHistoryClick,
onDelete,
} = props;
return (
<div>
<h2 className="ml-2 text-xs text-gray-500">{title}</h2>
<ul className="mt-1 space-y-0.5">
{histories.map((chatHistory) => {
return (
<ChatHistoryItem
key={chatHistory._id}
chatHistory={chatHistory}
isActive={activeChatHistoryId === chatHistory._id}
onChatHistoryClick={onChatHistoryClick}
onDelete={() => {
onDelete?.(chatHistory._id);
}}
/>
);
})}
</ul>
</div>
);
}
@@ -0,0 +1,33 @@
import { cn } from '../../lib/classname';
import type { ChatHistoryDocument } from '../../queries/chat-history';
import { ChatHistoryAction } from './ChatHistoryAction';
type ChatHistoryItemProps = {
chatHistory: Omit<ChatHistoryDocument, 'messages'>;
isActive: boolean;
onChatHistoryClick: (chatHistoryId: string) => void;
onDelete?: () => void;
};
export function ChatHistoryItem(props: ChatHistoryItemProps) {
const { chatHistory, isActive, onChatHistoryClick, onDelete } = props;
return (
<li key={chatHistory._id} className="group/item relative text-sm">
<button
className="block w-full truncate rounded-lg p-2 pr-10 text-left hover:bg-gray-100 data-[active=true]:bg-gray-100"
data-active={isActive}
onClick={() => onChatHistoryClick(chatHistory._id)}
>
{chatHistory.title}
</button>
<div className="absolute inset-y-0 right-2 flex items-center">
<ChatHistoryAction
chatHistoryId={chatHistory._id}
onDelete={onDelete}
/>
</div>
</li>
);
}
@@ -0,0 +1,292 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { listChatHistoryOptions } from '../../queries/chat-history';
import { queryClient } from '../../stores/query-client';
import {
Loader2Icon,
LockIcon,
PanelLeftCloseIcon,
PanelLeftIcon,
PlusIcon,
} from 'lucide-react';
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { ListChatHistorySkeleton } from './ListChatHistorySkeleton';
import { ChatHistoryError } from './ChatHistoryError';
import { cn } from '../../lib/classname';
import { getTailwindScreenDimension } from '../../lib/is-mobile';
import { groupChatHistory } from '../../helper/grouping';
import { SearchAIChatHistory } from './SearchAIChatHistory';
import { ChatHistoryGroup } from './ChatHistoryGroup';
import { isLoggedIn } from '../../lib/jwt';
import { CheckIcon } from '../ReactIcons/CheckIcon';
type ListChatHistoryProps = {
activeChatHistoryId?: string;
onChatHistoryClick: (chatHistoryId: string | null) => void;
onDelete?: (chatHistoryId: string) => void;
isPaidUser?: boolean;
onUpgrade?: () => void;
};
export function ListChatHistory(props: ListChatHistoryProps) {
const {
activeChatHistoryId,
onChatHistoryClick,
onDelete,
isPaidUser,
onUpgrade,
} = props;
const [isOpen, setIsOpen] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [isMobile, setIsMobile] = useState(false);
useLayoutEffect(() => {
const deviceType = getTailwindScreenDimension();
const isMediumSize = ['sm', 'md'].includes(deviceType);
// Only set initial state from localStorage if not on mobile
if (!isMediumSize) {
const storedState = localStorage.getItem('chat-history-sidebar-open');
setIsOpen(storedState === null ? true : storedState === 'true');
} else {
setIsOpen(!isMediumSize);
}
setIsMobile(isMediumSize);
}, []);
// Save state to localStorage when it changes, but only if not on mobile
useEffect(() => {
if (!isMobile) {
localStorage.setItem('chat-history-sidebar-open', isOpen.toString());
}
}, [isOpen, isMobile]);
const [query, setQuery] = useState('');
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isError,
error,
isLoading: isLoadingInfiniteQuery,
} = useInfiniteQuery(listChatHistoryOptions({ query }), queryClient);
useEffect(() => {
if (!data) {
return;
}
setIsLoading(false);
}, [data?.pages]);
const groupedChatHistory = useMemo(() => {
const allHistories = data?.pages?.flatMap((page) => page.data);
return groupChatHistory(allHistories ?? []);
}, [data?.pages]);
if (!isLoggedIn()) {
return null;
}
if (!isOpen) {
return (
<div className="absolute top-2 left-2 z-20 flex items-center gap-1">
<button
className="flex size-8 items-center justify-center rounded-lg p-1 hover:bg-gray-200"
onClick={() => {
setIsOpen(true);
}}
>
<PanelLeftIcon className="h-4.5 w-4.5" />
</button>
<button
className="flex size-8 items-center justify-center rounded-lg p-1 hover:bg-gray-200"
onClick={() => {
if (isMobile) {
setIsOpen(false);
}
onChatHistoryClick(null);
}}
>
<PlusIcon className="h-4.5 w-4.5" />
</button>
</div>
);
}
const isEmptyHistory = Object.values(groupedChatHistory ?? {}).every(
(group) => group.histories.length === 0,
);
const classNames = cn(
'flex w-[255px] shrink-0 flex-col justify-start border-r border-gray-200 bg-white p-2',
'max-md:absolute max-md:inset-0 max-md:z-20 max-md:w-full',
!isOpen && 'hidden',
);
const closeButton = (
<button
className="flex size-8 items-center justify-center rounded-lg p-1 text-gray-500 hover:bg-gray-100 hover:text-black"
onClick={() => {
setIsOpen(false);
}}
>
<PanelLeftCloseIcon className="h-4.5 w-4.5" />
</button>
);
if (!isPaidUser) {
return (
<UpgradeToProMessage
className={classNames}
closeButton={closeButton}
onUpgrade={onUpgrade}
/>
);
}
return (
<div className={classNames}>
{isLoading && <ListChatHistorySkeleton />}
{!isLoading && isError && <ChatHistoryError error={error} />}
{!isLoading && !isError && (
<>
<div>
<div className="mb-4 flex items-center justify-between">
<h1 className="font-medium text-gray-900">Chat History</h1>
{closeButton}
</div>
<button
className="flex w-full items-center justify-center gap-2 rounded-lg bg-black p-2 text-sm text-white hover:opacity-80"
onClick={() => {
if (isMobile) {
setIsOpen(false);
}
onChatHistoryClick(null);
}}
>
<PlusIcon className="h-4 w-4" />
<span className="text-sm">New Chat</span>
</button>
<SearchAIChatHistory
onSearch={setQuery}
isLoading={isLoadingInfiniteQuery}
/>
</div>
<div className="scrollbar-track-transparent scrollbar-thin scrollbar-thumb-gray-300 -mx-2 mt-6 grow space-y-4 overflow-y-scroll px-2">
{isEmptyHistory && !isLoadingInfiniteQuery && (
<div className="flex items-center justify-center">
<p className="text-sm text-gray-500">No chat history</p>
</div>
)}
{Object.entries(groupedChatHistory ?? {}).map(([key, value]) => {
if (value.histories.length === 0) {
return null;
}
return (
<ChatHistoryGroup
key={key}
title={value.title}
histories={value.histories}
activeChatHistoryId={activeChatHistoryId}
onChatHistoryClick={(id) => {
if (isMobile) {
setIsOpen(false);
}
onChatHistoryClick(id);
}}
onDelete={(id) => {
onDelete?.(id);
}}
/>
);
})}
{hasNextPage && (
<div className="mt-4">
<button
className="flex w-full items-center justify-center gap-2 text-sm text-gray-500 hover:text-black"
onClick={() => {
fetchNextPage();
}}
disabled={isFetchingNextPage}
>
{isFetchingNextPage && (
<>
<Loader2Icon className="h-4 w-4 animate-spin" />
Loading more...
</>
)}
{!isFetchingNextPage && 'Load More'}
</button>
</div>
)}
</div>
</>
)}
</div>
);
}
type UpgradeToProMessageProps = {
className?: string;
onUpgrade?: () => void;
closeButton?: React.ReactNode;
};
export function UpgradeToProMessage(props: UpgradeToProMessageProps) {
const { className, onUpgrade, closeButton } = props;
return (
<div className={cn('relative flex flex-col', className)}>
<div className="mb-4 flex items-center justify-between">
{closeButton}
</div>
<div className="flex grow flex-col items-center justify-center px-4">
<div className="flex flex-col items-center">
<div className="mb-3 rounded-full bg-yellow-100 p-3">
<LockIcon className="size-6 text-yellow-600" />
</div>
<h2 className="text-lg font-semibold text-gray-900">
Unlock History
</h2>
<p className="mt-2 text-center text-sm text-balance text-gray-600">
Save conversations and pick up right where you left off.
</p>
</div>
<div className="my-5 w-full space-y-2">
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
<CheckIcon additionalClasses="size-4 text-green-500" />
<span className="text-sm text-gray-600">Unlimited history</span>
</div>
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
<CheckIcon additionalClasses="size-4 text-green-500" />
<span className="text-sm text-gray-600">Search old chats</span>
</div>
</div>
<button
type="button"
className="w-full cursor-pointer rounded-lg bg-yellow-400 px-4 py-2 text-sm font-medium text-black hover:bg-yellow-500"
onClick={() => {
onUpgrade?.();
}}
>
Upgrade to Pro
</button>
</div>
</div>
);
}
@@ -0,0 +1,35 @@
export function ListChatHistorySkeleton() {
return (
<>
<div>
<div className="mb-4 flex items-center justify-between gap-2">
<div className="h-6 w-1/2 animate-pulse rounded bg-gray-200" />
<div className="size-8 animate-pulse rounded-md bg-gray-200" />
</div>
<div className="h-9 w-full animate-pulse rounded-lg bg-gray-200" />
<div className="relative mt-2">
<div className="h-9 w-full animate-pulse rounded-lg bg-gray-200" />
</div>
</div>
<div className="scrollbar-track-transparent scrollbar-thin scrollbar-thumb-gray-300 -mx-2 mt-6 grow space-y-4 overflow-y-scroll px-2">
{['Today', 'Last 7 Days', 'Older'].map((group) => (
<div key={group}>
<div className="h-4 w-16 animate-pulse rounded bg-gray-200" />
<ul className="mt-1 space-y-0.5">
{[1, 2, 3].map((i) => (
<li
key={i}
className="h-9 animate-pulse rounded-lg bg-gray-100"
></li>
))}
</ul>
</div>
))}
</div>
</>
);
}
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { useDebounceValue } from '../../hooks/use-debounce';
import { Loader2Icon, XIcon, SearchIcon } from 'lucide-react';
import { cn } from '../../lib/classname';
type SearchAIChatHistoryProps = {
onSearch: (search: string) => void;
isLoading?: boolean;
className?: string;
inputClassName?: string;
};
export function SearchAIChatHistory(props: SearchAIChatHistoryProps) {
const { onSearch, isLoading, className, inputClassName } = props;
const [search, setSearch] = useState('');
const debouncedSearch = useDebounceValue(search, 300);
useEffect(() => {
onSearch(debouncedSearch);
}, [debouncedSearch, onSearch]);
return (
<form
className={cn('relative mt-2 flex grow items-center', className)}
onSubmit={(e) => {
e.preventDefault();
onSearch(search);
}}
>
<input
type="text"
placeholder="Search folder by name"
className={cn(
'block h-9 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 pr-7 pl-8 text-sm outline-none placeholder:text-zinc-500 focus:border-zinc-500',
inputClassName,
)}
required
minLength={3}
maxLength={255}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="absolute top-1/2 left-2.5 -translate-y-1/2">
{isLoading ? (
<Loader2Icon className="size-4 animate-spin text-gray-500" />
) : (
<SearchIcon className="size-4 text-gray-500" />
)}
</div>
{search && (
<div className="absolute inset-y-0 right-1 flex items-center">
<button
onClick={() => {
setSearch('');
}}
className="rounded-lg p-1 hover:bg-gray-100"
>
<XIcon className="size-4 text-gray-500" />
</button>
</div>
)}
</form>
);
}
-5
View File
@@ -35,11 +35,6 @@ export function AITutorLayout(props: AITutorLayoutProps) {
'flex flex-grow flex-row lg:h-screen',
containerClassName,
)}
style={
{
'--ai-sidebar-width': '255px',
} as React.CSSProperties
}
>
<AITutorSidebar
onClose={() => setIsSidebarFloating(false)}
+1 -1
View File
@@ -94,7 +94,7 @@ export function AITutorSidebar(props: AITutorSidebarProps) {
<aside
className={cn(
'flex w-[var(--ai-sidebar-width)] shrink-0 flex-col border-r border-slate-200',
'flex w-[255px] shrink-0 flex-col border-r border-slate-200',
isFloating
? 'fixed top-0 bottom-0 left-0 z-50 flex border-r-0 bg-white shadow-xl'
: 'hidden lg:flex',
@@ -3,9 +3,11 @@ import type { JSONContent } from '@tiptap/core';
import {
BookOpen,
ChevronDown,
Loader2Icon,
MessageCirclePlus,
PauseCircleIcon,
PersonStanding,
Plus,
SendIcon,
SquareArrowOutUpRight,
Trash2,
@@ -16,23 +18,25 @@ import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { useKeydown } from '../../hooks/use-keydown';
import {
roadmapAIChatRenderer,
useRoadmapAIChat,
type RoadmapAIChatHistoryType,
} from '../../hooks/use-roadmap-ai-chat';
import { cn } from '../../lib/classname';
import { lockBodyScroll } from '../../lib/dom';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { slugify } from '../../lib/slugger';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { billingDetailsOptions } from '../../queries/billing';
import { chatHistoryOptions } from '../../queries/chat-history';
import { roadmapJSONOptions } from '../../queries/roadmap';
import { roadmapQuestionsOptions } from '../../queries/roadmap-questions';
import { queryClient } from '../../stores/query-client';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard';
import { RoadmapAIChatHistory } from '../RoadmapAIChatHistory/RoadmapAIChatHistory';
import { CLOSE_TOPIC_DETAIL_EVENT } from '../TopicDetail/TopicDetail';
import { UpdatePersonaModal } from '../UserPersona/UpdatePersonaModal';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
type ChatHeaderButtonProps = {
onClick?: () => void;
@@ -47,7 +51,7 @@ function ChatHeaderButton(props: ChatHeaderButtonProps) {
const { onClick, href, icon, children, className, target } = props;
const classNames = cn(
'flex items-center gap-1.5 text-xs text-gray-600 transition-colors hover:text-gray-900',
'flex shrink-0 items-center gap-1.5 text-xs text-gray-600 transition-colors hover:text-gray-900 min-w-8',
className,
);
@@ -227,6 +231,22 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
});
};
const [isChatHistoryLoading, setIsChatHistoryLoading] = useState(true);
const [activeChatHistoryId, setActiveChatHistoryId] = useState<
string | undefined
>();
const { data: chatHistory } = useQuery(
chatHistoryOptions(
activeChatHistoryId,
roadmapAIChatRenderer({
roadmapId,
totalTopicCount,
onSelectTopic,
}),
),
queryClient,
);
const {
aiChatHistory,
isStreamingMessage,
@@ -237,13 +257,39 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
handleAbort,
scrollToBottom,
clearChat,
setAiChatHistory,
} = useRoadmapAIChat({
activeChatHistoryId,
roadmapId,
totalTopicCount,
scrollareaRef,
onSelectTopic,
onChatHistoryIdChange: (chatHistoryId) => {
setActiveChatHistoryId(chatHistoryId);
},
});
useEffect(() => {
if (!chatHistory) {
return;
}
setAiChatHistory(chatHistory?.messages ?? []);
setIsChatHistoryLoading(false);
setTimeout(() => {
scrollToBottom('instant');
}, 0);
}, [chatHistory]);
useEffect(() => {
if (activeChatHistoryId) {
return;
}
setAiChatHistory([]);
setIsChatHistoryLoading(false);
}, [activeChatHistoryId, setAiChatHistory, setIsChatHistoryLoading]);
useEffect(() => {
lockBodyScroll(isOpen);
}, [isOpen]);
@@ -293,6 +339,7 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
};
const hasMessages = aiChatHistory.length > 0;
const newTabUrl = `/${roadmapId}/ai${activeChatHistoryId ? `?chatId=${activeChatHistoryId}` : ''}`;
return (
<>
@@ -330,32 +377,69 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
>
{isOpen && (
<>
<div className="flex h-full w-full flex-col overflow-hidden rounded-lg bg-white shadow-lg">
{/* Messages area */}
<div className="relative flex h-full w-full flex-col overflow-hidden rounded-lg bg-white shadow-lg">
{isChatHistoryLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white">
<div className="flex items-center rounded-md border border-gray-200 py-2 pr-3 pl-2">
<Loader2Icon className="size-4 animate-spin stroke-[2.5] text-gray-400" />
<span className="ml-2 text-sm text-gray-500">
Loading history..
</span>
</div>
</div>
)}
<div className="flex items-center justify-between px-3 py-2">
<div className="flex">
<ChatHeaderButton
icon={<BookOpen className="h-3.5 w-3.5" />}
className="text-sm"
className="pointer-events-none text-sm"
>
AI Tutor
{chatHistory?.title || 'AI Tutor'}
</ChatHeaderButton>
</div>
<div className="flex gap-1.5">
{isPaidUser && activeChatHistoryId && (
<ChatHeaderButton
onClick={() => {
setActiveChatHistoryId(undefined);
inputRef.current?.focus();
}}
icon={<Plus className="h-3.5 w-3.5" />}
className="justify-center rounded-md bg-gray-200 px-2 py-1 text-xs text-black hover:bg-gray-300"
/>
)}
<RoadmapAIChatHistory
roadmapId={roadmapId}
activeChatHistoryId={activeChatHistoryId}
onChatHistoryClick={(chatHistoryId) => {
setIsChatHistoryLoading(true);
setActiveChatHistoryId(chatHistoryId);
setShowScrollToBottom(false);
}}
onDelete={(chatHistoryId) => {
if (activeChatHistoryId === chatHistoryId) {
setActiveChatHistoryId(undefined);
}
}}
onUpgrade={() => {
setShowUpgradeModal(true);
}}
/>
<ChatHeaderButton
href={`/${roadmapId}/ai`}
href={newTabUrl}
target="_blank"
icon={<SquareArrowOutUpRight className="h-3.5 w-3.5" />}
className="hidden rounded-md py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300 sm:flex"
>
Open in new tab
</ChatHeaderButton>
className="hidden justify-center rounded-md bg-gray-200 px-1 py-1 text-gray-500 hover:bg-gray-300 sm:flex"
/>
<ChatHeaderButton
onClick={() => setIsOpen(false)}
icon={<X className="h-3.5 w-3.5" />}
className="rounded-md bg-red-100 px-1 py-1 text-red-500 hover:bg-red-200"
className="flex items-center justify-center rounded-md bg-red-100 px-1 py-1 text-red-500 hover:bg-red-200"
/>
</div>
</div>
@@ -412,13 +496,11 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
</div>
)}
{aiChatHistory.map(
(chat: RoadmapAIChatHistoryType, index: number) => (
<Fragment key={`chat-${index}`}>
<RoadmapAIChatCard {...chat} />
</Fragment>
),
)}
{aiChatHistory.map((chat, index) => (
<Fragment key={`chat-${index}`}>
<RoadmapAIChatCard {...chat} />
</Fragment>
))}
{isStreamingMessage && !streamedMessage && (
<RoadmapAIChatCard role="assistant" html="Thinking..." />
@@ -444,7 +526,6 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
)}
</div>
{/* Input area */}
{isLimitExceeded && (
<UpgradeMessage
onUpgradeClick={() => {
@@ -482,7 +563,7 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
/>
)}
</div>
{hasMessages && (
{hasMessages && !isPaidUser && (
<ChatHeaderButton
onClick={() => {
setInputValue('');
@@ -550,7 +631,7 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
{!isOpen && (
<button
className={cn(
'relative mx-auto flex flex-shrink-0 cursor-pointer 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 w-max',
'relative mx-auto flex w-max flex-shrink-0 cursor-pointer 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',
)}
onClick={() => {
setIsOpen(true);
@@ -566,10 +647,10 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
<span className="mr-1 text-sm font-semibold text-yellow-400">
AI Tutor
</span>
<span className={'text-white hidden sm:block'}>
<span className={'hidden text-white sm:block'}>
Have a question? Type here
</span>
<span className={'text-white block sm:hidden'}>
<span className={'block text-white sm:hidden'}>
Ask anything
</span>
</>
+28
View File
@@ -0,0 +1,28 @@
import * as PopoverPrimitive from '@radix-ui/react-popover';
import * as React from 'react';
import { cn } from '../lib/classname';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-lg border border-gray-200 bg-white p-2 text-black shadow-sm outline-none',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverContent, PopoverTrigger };
@@ -24,10 +24,12 @@ type AIChatActionButtonsProps = {
onTellUsAboutYourSelf: () => void;
onClearChat: () => void;
messageCount: number;
showClearChat: boolean;
};
export function AIChatActionButtons(props: AIChatActionButtonsProps) {
const { onTellUsAboutYourSelf, onClearChat, messageCount } = props;
const { onTellUsAboutYourSelf, onClearChat, messageCount, showClearChat } =
props;
return (
<div className="flex gap-2 px-4 pt-2">
@@ -36,7 +38,7 @@ export function AIChatActionButtons(props: AIChatActionButtonsProps) {
label="Tell us about your self"
onClick={onTellUsAboutYourSelf}
/>
{messageCount > 0 && (
{showClearChat && messageCount > 0 && (
<AIChatActionButton
icon={Trash2}
label="Clear chat"
+177 -73
View File
@@ -1,8 +1,17 @@
import './RoadmapAIChat.css';
import { useQuery } from '@tanstack/react-query';
import { roadmapJSONOptions } from '../../queries/roadmap';
import { queryClient } from '../../stores/query-client';
import type { Editor, JSONContent } from '@tiptap/core';
import {
Bot,
Frown,
HistoryIcon,
Loader2Icon,
LockIcon,
PauseCircleIcon,
SendIcon,
XIcon,
} from 'lucide-react';
import {
Fragment,
useCallback,
@@ -12,45 +21,41 @@ import {
useRef,
useState,
} from 'react';
import {
Bot,
Frown,
Loader2Icon,
LockIcon,
PauseCircleIcon,
SendIcon,
} from 'lucide-react';
import { ChatEditor } from '../ChatEditor/ChatEditor';
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
import { isLoggedIn } from '../../lib/jwt';
import type { JSONContent, Editor } from '@tiptap/core';
import { flushSync } from 'react-dom';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import {
roadmapAIChatRenderer,
useRoadmapAIChat,
type RoadmapAIChatHistoryType,
} from '../../hooks/use-roadmap-ai-chat';
import { useToast } from '../../hooks/use-toast';
import { userResourceProgressOptions } from '../../queries/resource-progress';
import { ChatRoadmapRenderer } from './ChatRoadmapRenderer';
import { RoadmapAIChatCard } from './RoadmapAIChatCard';
import { RoadmapAIChatHeader } from './RoadmapAIChatHeader';
import { showLoginPopup } from '../../lib/popup';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { billingDetailsOptions } from '../../queries/billing';
import { TopicDetail } from '../TopicDetail/TopicDetail';
import { slugify } from '../../lib/slugger';
import { AIChatActionButtons } from './AIChatActionButtons';
import { deleteUrlParam, getUrlParams } from '../../lib/browser';
import { cn } from '../../lib/classname';
import { lockBodyScroll } from '../../lib/dom';
import {
getTailwindScreenDimension,
type TailwindScreenDimensions,
} from '../../lib/is-mobile';
import { ChatPersona } from '../UserPersona/ChatPersona';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { slugify } from '../../lib/slugger';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { billingDetailsOptions } from '../../queries/billing';
import { chatHistoryOptions } from '../../queries/chat-history';
import { userResourceProgressOptions } from '../../queries/resource-progress';
import { roadmapJSONOptions } from '../../queries/roadmap';
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
import { userRoadmapPersonaOptions } from '../../queries/user-persona';
import { queryClient } from '../../stores/query-client';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { ChatEditor } from '../ChatEditor/ChatEditor';
import { TopicDetail } from '../TopicDetail/TopicDetail';
import { ChatPersona } from '../UserPersona/ChatPersona';
import { UpdatePersonaModal } from '../UserPersona/UpdatePersonaModal';
import { lockBodyScroll } from '../../lib/dom';
import { AIChatActionButtons } from './AIChatActionButtons';
import { ChatRoadmapRenderer } from './ChatRoadmapRenderer';
import { RoadmapAIChatCard } from './RoadmapAIChatCard';
import { RoadmapAIChatHeader } from './RoadmapAIChatHeader';
import { TutorIntroMessage } from './TutorIntroMessage';
import {
useRoadmapAIChat,
type RoadmapAIChatHistoryType,
} from '../../hooks/use-roadmap-ai-chat';
export type RoadmapAIChatTab = 'chat' | 'topic';
@@ -79,6 +84,9 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
null,
);
const [activeTab, setActiveTab] = useState<RoadmapAIChatTab>('chat');
const [activeChatHistoryId, setActiveChatHistoryId] = useState<
string | undefined
>();
const [showUpdatePersonaModal, setShowUpdatePersonaModal] = useState(false);
@@ -136,10 +144,19 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
}, [roadmapDetail]);
useEffect(() => {
const params = getUrlParams();
const queryChatId = params.chatId;
if (!roadmapTreeData || !roadmapDetail || isUserPersonaLoading) {
return;
}
if (queryChatId) {
setIsChatHistoryLoading(true);
setActiveChatHistoryId(queryChatId);
deleteUrlParam('chatId');
}
setIsLoading(false);
}, [roadmapTreeData, roadmapDetail, isUserPersonaLoading]);
@@ -170,6 +187,19 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
[roadmapId, deviceType],
);
const [isChatHistoryLoading, setIsChatHistoryLoading] = useState(true);
const { data: chatHistory } = useQuery(
chatHistoryOptions(
activeChatHistoryId,
roadmapAIChatRenderer({
roadmapId,
totalTopicCount,
onSelectTopic,
}),
),
queryClient,
);
const {
aiChatHistory,
isStreamingMessage,
@@ -179,13 +209,39 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
handleAbort,
clearChat,
scrollToBottom,
setAiChatHistory,
} = useRoadmapAIChat({
activeChatHistoryId,
roadmapId,
totalTopicCount,
scrollareaRef,
onSelectTopic,
onChatHistoryIdChange: (chatHistoryId) => {
setActiveChatHistoryId(chatHistoryId);
},
});
useEffect(() => {
if (!chatHistory) {
return;
}
setAiChatHistory(chatHistory?.messages ?? []);
setIsChatHistoryLoading(false);
setTimeout(() => {
scrollToBottom('instant');
}, 0);
}, [chatHistory]);
useEffect(() => {
if (activeChatHistoryId) {
return;
}
setAiChatHistory([]);
setIsChatHistoryLoading(false);
}, [activeChatHistoryId, setAiChatHistory, setIsChatHistoryLoading]);
if (roadmapDetailError) {
return (
<div className="flex flex-grow flex-col items-center justify-center">
@@ -307,6 +363,21 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
setActiveTab('chat');
}}
selectedTopicId={selectedTopicId}
roadmapId={roadmapId}
activeChatHistoryId={activeChatHistoryId}
onChatHistoryClick={(chatHistoryId) => {
setIsChatHistoryLoading(true);
setActiveChatHistoryId(chatHistoryId);
}}
onNewChat={() => {
document.title = 'Roadmap AI Chat';
setActiveChatHistoryId(undefined);
}}
onDeleteChatHistory={(chatHistoryId) => {
if (activeChatHistoryId === chatHistoryId) {
setActiveChatHistoryId(undefined);
}
}}
/>
{activeTab === 'topic' && selectedTopicId && (
@@ -333,62 +404,77 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
{activeTab === 'chat' && (
<>
{!!chatHistory && isPaidUser && !isChatHistoryLoading && (
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-gray-100 px-3 py-2 text-sm text-gray-500">
<h3 className="flex min-w-0 items-center gap-2">
<HistoryIcon className="size-4 shrink-0" />
<span className="truncate">{chatHistory.title}</span>
</h3>
<button
onClick={() => {
setActiveChatHistoryId(undefined);
}}
className="text-sm text-gray-500 hover:text-gray-700"
>
<XIcon className="size-4" />
</button>
</div>
)}
<div className="relative grow overflow-y-auto" ref={scrollareaRef}>
{isLoading && (
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white p-1.5 px-3 text-sm text-gray-500">
<Loader2Icon className="size-4 animate-spin stroke-[2.5]" />
<span>Loading Roadmap</span>
</div>
</div>
{isLoading && <Loader />}
{isChatHistoryLoading && (
<Loader message="Loading chat history" />
)}
{shouldShowChatPersona && !isLoading && (
{shouldShowChatPersona && !isLoading && !isChatHistoryLoading && (
<ChatPersona roadmapId={roadmapId} />
)}
{!isLoading && !shouldShowChatPersona && (
<div className="absolute inset-0 flex flex-col">
<div className="relative flex grow flex-col justify-end">
<div className="flex flex-col justify-end gap-2 px-3 py-2">
<RoadmapAIChatCard
role="assistant"
jsx={
<TutorIntroMessage roadmap={roadmapDetail?.json!} />
}
isIntro
/>
{aiChatHistory.map(
(chat: RoadmapAIChatHistoryType, index: number) => {
return (
<Fragment key={`chat-${index}`}>
<RoadmapAIChatCard {...chat} />
</Fragment>
);
},
)}
{isStreamingMessage && !streamedMessage && (
{!isLoading &&
!isChatHistoryLoading &&
!shouldShowChatPersona && (
<div className="absolute inset-0 flex flex-col">
<div className="relative flex grow flex-col justify-end">
<div className="flex flex-col justify-end gap-2 px-3 py-2">
<RoadmapAIChatCard
role="assistant"
html="Thinking..."
jsx={
<TutorIntroMessage roadmap={roadmapDetail?.json!} />
}
isIntro
/>
)}
{streamedMessage && (
<RoadmapAIChatCard
role="assistant"
jsx={streamedMessage}
/>
)}
{aiChatHistory.map(
(chat: RoadmapAIChatHistoryType, index: number) => {
return (
<Fragment key={`chat-${index}`}>
<RoadmapAIChatCard {...chat} />
</Fragment>
);
},
)}
{isStreamingMessage && !streamedMessage && (
<RoadmapAIChatCard
role="assistant"
html="Thinking..."
/>
)}
{streamedMessage && (
<RoadmapAIChatCard
role="assistant"
jsx={streamedMessage}
/>
)}
</div>
</div>
</div>
</div>
)}
)}
</div>
{!isLoading && !shouldShowChatPersona && (
{!isLoading && !isChatHistoryLoading && !shouldShowChatPersona && (
<div className="flex flex-col border-t border-gray-200">
{!isLimitExceeded && (
<AIChatActionButtons
@@ -396,6 +482,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
setShowUpdatePersonaModal(true);
}}
messageCount={aiChatHistory.length}
showClearChat={!isPaidUser}
onClearChat={clearChat}
/>
)}
@@ -508,3 +595,20 @@ function isEmptyContent(content: JSONContent) {
(!firstContent?.content || firstContent?.content?.length === 0)
);
}
type LoaderProps = {
message?: string;
};
function Loader(props: LoaderProps) {
const { message } = props;
return (
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white p-1.5 px-3 text-sm text-gray-500">
<Loader2Icon className="size-4 animate-spin stroke-[2.5]" />
<span>{message ?? 'Loading Roadmap'}</span>
</div>
</div>
);
}
@@ -3,27 +3,14 @@ import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
import { billingDetailsOptions } from '../../queries/billing';
import { isLoggedIn } from '../../lib/jwt';
import { BookIcon, BotIcon, GiftIcon, XIcon } from 'lucide-react';
import { BookIcon, BotIcon, GiftIcon, PlusIcon, XIcon } from 'lucide-react';
import type { RoadmapAIChatTab } from './RoadmapAIChat';
import { useState } from 'react';
import { getPercentage } from '../../lib/number';
import { AILimitsPopup } from '../GenerateCourse/AILimitsPopup';
import { cn } from '../../lib/classname';
import { useKeydown } from '../../hooks/use-keydown';
type RoadmapAIChatHeaderProps = {
isLoading: boolean;
onLogin: () => void;
onUpgrade: () => void;
onCloseChat: () => void;
activeTab: RoadmapAIChatTab;
onTabChange: (tab: RoadmapAIChatTab) => void;
onCloseTopic: () => void;
selectedTopicId: string | null;
};
import { RoadmapAIChatHistory } from '../RoadmapAIChatHistory/RoadmapAIChatHistory';
type TabButtonProps = {
icon: React.ReactNode;
@@ -65,6 +52,26 @@ function TabButton(props: TabButtonProps) {
);
}
type RoadmapAIChatHeaderProps = {
isLoading: boolean;
onLogin: () => void;
onUpgrade: () => void;
onCloseChat: () => void;
activeTab: RoadmapAIChatTab;
onTabChange: (tab: RoadmapAIChatTab) => void;
onCloseTopic: () => void;
selectedTopicId: string | null;
roadmapId: string;
activeChatHistoryId?: string;
onChatHistoryClick: (chatHistoryId: string) => void;
onNewChat: () => void;
onDeleteChatHistory: (chatHistoryId: string) => void;
};
export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
const {
onLogin,
@@ -76,6 +83,11 @@ export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
onTabChange,
onCloseTopic,
selectedTopicId,
roadmapId,
activeChatHistoryId,
onChatHistoryClick,
onNewChat,
onDeleteChatHistory,
} = props;
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
@@ -146,15 +158,18 @@ export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
{!isDataLoading && isLoggedIn() && (
<div className="flex gap-1.5 pr-4">
{isPaidUser && (
<button
className="flex items-center gap-1 rounded-md bg-gray-200 px-2 py-1 text-xs text-black hover:bg-gray-300"
onClick={onNewChat}
>
<PlusIcon className="size-4" />
New Chat
</button>
)}
{!isPaidUser && (
<>
<button
className="hidden rounded-md bg-gray-200 px-2 py-1 text-sm hover:bg-gray-300 2xl:block"
onClick={handleCreditsClick}
>
<span className="font-medium">{usagePercentage}%</span> limit
used
</button>
<button
className="flex items-center gap-1 rounded-md bg-yellow-400 px-2 py-1 text-sm text-black hover:bg-yellow-500"
onClick={handleUpgradeClick}
@@ -162,14 +177,21 @@ export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
<GiftIcon className="size-4" />
Upgrade
</button>
<button
className="hidden items-center gap-1 rounded-md bg-gray-200 px-2 py-1 text-sm text-black hover:bg-gray-300 max-xl:flex"
onClick={onCloseChat}
>
<XIcon className="size-3.5" strokeWidth={2.5} />
</button>
</>
)}
<RoadmapAIChatHistory
roadmapId={roadmapId}
onChatHistoryClick={onChatHistoryClick}
activeChatHistoryId={activeChatHistoryId}
onDelete={onDeleteChatHistory}
onUpgrade={onUpgrade}
/>
<button
className="hidden items-center gap-1 rounded-md bg-gray-200 px-2 py-1 text-sm text-black hover:bg-gray-300 max-xl:flex"
onClick={onCloseChat}
>
<XIcon className="size-3.5" strokeWidth={2.5} />
</button>
</div>
)}
</div>
@@ -0,0 +1,183 @@
import { HistoryIcon, Loader2Icon } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '../Popover';
import { useEffect, useMemo, useState } from 'react';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { listChatHistoryOptions } from '../../queries/chat-history';
import { isLoggedIn } from '../../lib/jwt';
import { groupChatHistory } from '../../helper/grouping';
import { ChatHistoryGroup } from '../AIChatHistory/ChatHistoryGroup';
import { queryClient } from '../../stores/query-client';
import { SearchAIChatHistory } from '../AIChatHistory/SearchAIChatHistory';
import { billingDetailsOptions } from '../../queries/billing';
import { UpgradeToProMessage } from '../AIChatHistory/ListChatHistory';
import { showLoginPopup } from '../../lib/popup';
type RoadmapAIChatHistoryProps = {
roadmapId: string;
activeChatHistoryId?: string;
activeChatHistoryTitle?: string;
onChatHistoryClick: (id: string) => void;
onDelete?: (id: string) => void;
onUpgrade?: () => void;
};
export function RoadmapAIChatHistory(props: RoadmapAIChatHistoryProps) {
const {
roadmapId,
activeChatHistoryId,
activeChatHistoryTitle,
onChatHistoryClick,
onDelete,
onUpgrade,
} = props;
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [query, setQuery] = useState('');
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
useQuery(billingDetailsOptions(), queryClient);
const isPaidUser = userBillingDetails?.status === 'active';
const {
data: chatHistory,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
isLoading: isLoadingInfiniteQuery,
} = useInfiniteQuery(
{
...listChatHistoryOptions({
roadmapId,
query,
}),
enabled: !!roadmapId && isLoggedIn() && isOpen && isPaidUser,
},
queryClient,
);
// no initial spinner if not paid user
// because we won't fetch the data
useEffect(() => {
if (!isPaidUser) {
setIsLoading(false);
}
}, [isPaidUser]);
useEffect(() => {
if (!chatHistory || isBillingDetailsLoading) {
return;
}
setIsLoading(false);
}, [chatHistory, isBillingDetailsLoading]);
const groupedChatHistory = useMemo(() => {
const allHistories = chatHistory?.pages?.flatMap((page) => page.data);
return groupChatHistory(allHistories ?? []);
}, [chatHistory?.pages]);
const isEmptyHistory = Object.values(groupedChatHistory ?? {}).every(
(group) => group.histories.length === 0,
);
return (
<Popover
open={isOpen}
onOpenChange={(open) => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setIsOpen(open);
}}
>
<PopoverTrigger className="flex items-center justify-center gap-2 rounded-md bg-gray-200 px-3 py-1.5 text-xs text-gray-900 hover:bg-gray-300 hover:text-black">
<HistoryIcon className="size-3.5" />
{activeChatHistoryTitle || 'Chat History'}
</PopoverTrigger>
<PopoverContent
className="z-[999] flex max-h-[400px] w-80 flex-col overflow-hidden p-0 shadow-lg"
align="end"
sideOffset={4}
>
{isLoading && (
<div className="flex items-center justify-center py-10">
<Loader2Icon className="size-6 animate-spin stroke-[2.5] text-gray-400" />
</div>
)}
{!isLoading && !isPaidUser && (
<UpgradeToProMessage
className="mt-0 px-10 py-10"
onUpgrade={() => {
setIsOpen(false);
onUpgrade?.();
}}
/>
)}
{!isLoading && isPaidUser && (
<>
<SearchAIChatHistory
onSearch={setQuery}
isLoading={isLoadingInfiniteQuery}
className="mt-0"
inputClassName="border-x-0 border-t-0 border-b border-b-gray-200 rounded-none focus:border-b-gray-200"
/>
<div className="scrollbar-track-transparent scrollbar-thin scrollbar-thumb-gray-300 grow space-y-4 overflow-y-auto p-2 pt-4">
{isEmptyHistory && (
<div className="flex items-center justify-center py-10">
<p className="text-sm text-gray-500">No chat history</p>
</div>
)}
{Object.entries(groupedChatHistory ?? {}).map(([key, value]) => {
if (value.histories.length === 0) {
return null;
}
return (
<ChatHistoryGroup
key={key}
title={value.title}
histories={value.histories}
activeChatHistoryId={activeChatHistoryId}
onChatHistoryClick={(id) => {
setIsOpen(false);
onChatHistoryClick(id);
}}
onDelete={(id) => {
setIsOpen(false);
onDelete?.(id);
}}
/>
);
})}
{hasNextPage && (
<div className="mt-4">
<button
className="flex w-full items-center justify-center gap-2 text-sm text-gray-500 hover:text-black"
onClick={() => {
fetchNextPage();
}}
disabled={isFetchingNextPage}
>
{isFetchingNextPage && (
<>
<Loader2Icon className="h-4 w-4 animate-spin" />
Loading more...
</>
)}
{!isFetchingNextPage && 'Load More'}
</button>
</div>
)}
</div>
</>
)}
</PopoverContent>
</Popover>
);
}