import { useEffect, useMemo, useRef } from 'react' import { createFileRoute, useParams, redirect, useNavigate } from '@tanstack/react-router' import cloneDeep from 'lodash.clonedeep' import { cn } from '@/lib/utils' import { toast } from 'sonner' import { useTranslation } from '@/i18n/react-i18next-compat' import HeaderPage from '@/containers/HeaderPage' import { useThreads } from '@/hooks/useThreads' import ChatInput from '@/containers/ChatInput' import { useShallow } from 'zustand/react/shallow' import { ThreadContent } from '@/containers/ThreadContent' import { StreamingContent } from '@/containers/StreamingContent' import { useMessages } from '@/hooks/useMessages' import { useServiceHub } from '@/hooks/useServiceHub' import DropdownAssistant from '@/containers/DropdownAssistant' import { useAssistant } from '@/hooks/useAssistant' import { useInterfaceSettings } from '@/hooks/useInterfaceSettings' import { ContentType, ThreadMessage } from '@janhq/core' import { useSmallScreen, useMobileScreen } from '@/hooks/useMediaQuery' import { useTools } from '@/hooks/useTools' import { PlatformFeatures } from '@/lib/platform/const' import { PlatformFeature } from '@/lib/platform/types' import ScrollToBottom from '@/containers/ScrollToBottom' import { PromptProgress } from '@/components/PromptProgress' import { ThreadPadding } from '@/containers/ThreadPadding' import { TEMPORARY_CHAT_ID, TEMPORARY_CHAT_QUERY_ID } from '@/constants/chat' import { IconInfoCircle } from '@tabler/icons-react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' const CONVERSATION_NOT_FOUND_EVENT = 'conversation-not-found' const TemporaryChatIndicator = ({ t }: { t: (key: string) => string }) => { return (
{t('common:temporaryChat')}

{t('common:temporaryChatTooltip')}

) } // as route.threadsDetail export const Route = createFileRoute('/threads/$threadId')({ beforeLoad: ({ params }) => { // Check if this is the temporary chat being accessed directly if (params.threadId === TEMPORARY_CHAT_ID) { // Check if we have the navigation flag in sessionStorage const hasNavigationFlag = sessionStorage.getItem('temp-chat-nav') if (!hasNavigationFlag) { // Direct access - redirect to home with query parameter throw redirect({ to: '/', search: { [TEMPORARY_CHAT_QUERY_ID]: true }, replace: true, }) } // Clear the flag immediately after checking sessionStorage.removeItem('temp-chat-nav') } }, component: ThreadDetail, }) function ThreadDetail() { const serviceHub = useServiceHub() const { threadId } = useParams({ from: Route.id }) const navigate = useNavigate() const { t } = useTranslation() const setCurrentThreadId = useThreads((state) => state.setCurrentThreadId) const setCurrentAssistant = useAssistant((state) => state.setCurrentAssistant) const assistants = useAssistant((state) => state.assistants) const setMessages = useMessages((state) => state.setMessages) const chatWidth = useInterfaceSettings((state) => state.chatWidth) const isSmallScreen = useSmallScreen() const isMobile = useMobileScreen() useTools() const { messages } = useMessages( useShallow((state) => ({ messages: state.messages[threadId], })) ) // Subscribe directly to the thread data to ensure updates when model changes const thread = useThreads(useShallow((state) => state.threads[threadId])) const scrollContainerRef = useRef(null) // Listen for conversation not found events useEffect(() => { const handleConversationNotFound = (event: CustomEvent) => { const { threadId: notFoundThreadId } = event.detail if (notFoundThreadId === threadId) { // Skip error handling for temporary chat - it's expected to not exist on server if (threadId === TEMPORARY_CHAT_ID) { return } toast.error(t('common:conversationNotAvailable'), { description: t('common:conversationNotAvailableDescription') }) navigate({ to: '/', replace: true }) } } window.addEventListener(CONVERSATION_NOT_FOUND_EVENT, handleConversationNotFound as EventListener) return () => { window.removeEventListener(CONVERSATION_NOT_FOUND_EVENT, handleConversationNotFound as EventListener) } }, [threadId, navigate, t]) useEffect(() => { setCurrentThreadId(threadId) const assistant = assistants.find( (assistant) => assistant.id === thread?.assistants?.[0]?.id ) if (assistant) setCurrentAssistant(assistant) // eslint-disable-next-line react-hooks/exhaustive-deps }, [threadId, assistants]) useEffect(() => { serviceHub .messages() .fetchMessages(threadId) .then((fetchedMessages) => { if (fetchedMessages) { // For web platform: preserve local messages if server fetch is empty but we have local messages if (PlatformFeatures[PlatformFeature.FIRST_MESSAGE_PERSISTED_THREAD] && fetchedMessages.length === 0 && messages && messages.length > 0) { console.log('!!!Preserving local messages as server fetch is empty:', messages.length) // Don't override local messages with empty server response return } // Update the messages in the store setMessages(threadId, fetchedMessages) } }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [threadId, serviceHub]) useEffect(() => { return () => { // Clear the current thread ID when the component unmounts setCurrentThreadId(undefined) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const updateMessage = ( item: ThreadMessage, message: string, imageUrls?: string[] ) => { const newMessages: ThreadMessage[] = messages.map((m) => { if (m.id === item.id) { const msg: ThreadMessage = cloneDeep(m) const newContent = [ { type: ContentType.Text, text: { value: message, annotations: m.content[0].text?.annotations ?? [], }, }, ] // Add image content if imageUrls are provided if (imageUrls && imageUrls.length > 0) { imageUrls.forEach((url) => { newContent.push({ type: 'image_url' as ContentType, image_url: { url: url, }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any) }) } msg.content = newContent return msg } return m }) setMessages(threadId, newMessages) } const threadModel = useMemo(() => thread?.model, [thread]) if (!messages || !threadModel) return null return (
{PlatformFeatures[PlatformFeature.ASSISTANTS] && ( )}
{threadId === TEMPORARY_CHAT_ID && }
{messages && messages.map((item, index) => { // Only pass isLastMessage to the last message in the array const isLastMessage = index === messages.length - 1 return (
) })} {/* Persistent padding element for ChatGPT-style message positioning */}
) }