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 */}
)
}