From de10c05eef4c1c993349251fab7f6bf2b829775f Mon Sep 17 00:00:00 2001 From: phact Date: Fri, 29 Aug 2025 16:50:20 -0400 Subject: [PATCH] fix: new chat history glitches --- frontend/components/navigation.tsx | 143 ++++++++++++++++---- frontend/src/app/chat/page.tsx | 106 ++++++++++++--- frontend/src/app/login/page.tsx | 10 +- frontend/src/components/layout-wrapper.tsx | 15 ++ frontend/src/components/protected-route.tsx | 16 ++- frontend/src/components/user-nav.tsx | 16 ++- frontend/src/contexts/auth-context.tsx | 39 +++++- frontend/src/contexts/chat-context.tsx | 26 ++++ frontend/src/contexts/task-context.tsx | 10 +- 9 files changed, 312 insertions(+), 69 deletions(-) diff --git a/frontend/components/navigation.tsx b/frontend/components/navigation.tsx index a2d6e08b..be61885c 100644 --- a/frontend/components/navigation.tsx +++ b/frontend/components/navigation.tsx @@ -47,15 +47,21 @@ interface ChatConversation { export function Navigation() { const pathname = usePathname() - const { endpoint, refreshTrigger, loadConversation, currentConversationId, setCurrentConversationId, conversationDocs, addConversationDoc } = useChat() + const { endpoint, refreshTrigger, loadConversation, currentConversationId, setCurrentConversationId, startNewConversation, conversationDocs, addConversationDoc, refreshConversations, placeholderConversation, setPlaceholderConversation } = useChat() const [conversations, setConversations] = useState([]) const [loadingConversations, setLoadingConversations] = useState(false) + const [previousConversationCount, setPreviousConversationCount] = useState(0) const fileInputRef = useRef(null) const handleNewConversation = () => { - setCurrentConversationId(null) - // The chat page will handle resetting messages when it detects a new conversation request - window.dispatchEvent(new CustomEvent('newConversation')) + // Ensure current conversation appears in sidebar before starting a new one + refreshConversations() + // Use context helper to fully reset conversation state + startNewConversation() + // Notify chat view even if state was already 'new' + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('newConversation')) + } } const handleFileUpload = async (file: File) => { @@ -173,8 +179,44 @@ export function Navigation() { }) setConversations(conversations) + + // If no conversations exist and no placeholder is shown, create a default placeholder + if (conversations.length === 0 && !placeholderConversation) { + const defaultPlaceholder: ChatConversation = { + response_id: 'new-conversation-' + Date.now(), + title: 'New conversation', + endpoint: endpoint, + messages: [{ + role: 'assistant', + content: 'How can I assist?', + timestamp: new Date().toISOString() + }], + created_at: new Date().toISOString(), + last_activity: new Date().toISOString(), + total_messages: 1 + } + setPlaceholderConversation(defaultPlaceholder) + } } else { setConversations([]) + + // Also create placeholder when request fails and no conversations exist + if (!placeholderConversation) { + const defaultPlaceholder: ChatConversation = { + response_id: 'new-conversation-' + Date.now(), + title: 'New conversation', + endpoint: endpoint, + messages: [{ + role: 'assistant', + content: 'How can I assist?', + timestamp: new Date().toISOString() + }], + created_at: new Date().toISOString(), + last_activity: new Date().toISOString(), + total_messages: 1 + } + setPlaceholderConversation(defaultPlaceholder) + } } // Conversation documents are now managed in chat context @@ -194,6 +236,24 @@ export function Navigation() { } }, [isOnChatPage, endpoint, refreshTrigger, fetchConversations]) + // Clear placeholder when conversation count increases (new conversation was created) + useEffect(() => { + const currentCount = conversations.length + + // If we had a placeholder and the conversation count increased, clear the placeholder and highlight the new conversation + if (placeholderConversation && currentCount > previousConversationCount && conversations.length > 0) { + setPlaceholderConversation(null) + // Highlight the most recent conversation (first in sorted array) without loading its messages + const newestConversation = conversations[0] + if (newestConversation) { + setCurrentConversationId(newestConversation.response_id) + } + } + + // Update the previous count + setPreviousConversationCount(currentCount) + }, [conversations.length, placeholderConversation, setPlaceholderConversation, previousConversationCount, conversations, setCurrentConversationId]) + return (
@@ -244,32 +304,57 @@ export function Navigation() {
{loadingConversations ? (
Loading...
- ) : conversations.length === 0 ? ( -
No conversations yet
) : ( - conversations.map((conversation) => ( -
{ - loadConversation(conversation) - }} - > -
- {conversation.title} -
-
- {conversation.total_messages} messages -
- {conversation.last_activity && ( -
- {new Date(conversation.last_activity).toLocaleDateString()} + <> + {/* Show placeholder conversation if it exists */} + {placeholderConversation && ( +
{ + // Don't load placeholder as a real conversation, just focus the input + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('focusInput')) + } + }} + > +
+ {placeholderConversation.title}
- )} -
- )) +
+ Start typing to begin... +
+
+ )} + + {/* Show regular conversations */} + {conversations.length === 0 && !placeholderConversation ? ( +
No conversations yet
+ ) : ( + conversations.map((conversation) => ( +
{ + loadConversation(conversation) + }} + > +
+ {conversation.title} +
+
+ {conversation.total_messages} messages +
+ {conversation.last_activity && ( +
+ {new Date(conversation.last_activity).toLocaleDateString()} +
+ )} +
+ )) + )} + )}
@@ -316,4 +401,4 @@ export function Navigation() { )}
) -} \ No newline at end of file +} diff --git a/frontend/src/app/chat/page.tsx b/frontend/src/app/chat/page.tsx index e972af2a..995063d6 100644 --- a/frontend/src/app/chat/page.tsx +++ b/frontend/src/app/chat/page.tsx @@ -70,7 +70,7 @@ interface RequestBody { function ChatPage() { const isDebugMode = process.env.NODE_ENV === 'development' || process.env.NEXT_PUBLIC_OPENRAG_DEBUG === 'true' const { user } = useAuth() - const { endpoint, setEndpoint, currentConversationId, conversationData, setCurrentConversationId, addConversationDoc, forkFromResponse } = useChat() + const { endpoint, setEndpoint, currentConversationId, conversationData, setCurrentConversationId, addConversationDoc, forkFromResponse, refreshConversations, previousResponseIds, setPreviousResponseIds, setPlaceholderConversation } = useChat() const [messages, setMessages] = useState([ { role: "assistant", @@ -87,10 +87,7 @@ function ChatPage() { timestamp: Date } | null>(null) const [expandedFunctionCalls, setExpandedFunctionCalls] = useState>(new Set()) - const [previousResponseIds, setPreviousResponseIds] = useState<{ - chat: string | null - langflow: string | null - }>({ chat: null, langflow: null }) + // previousResponseIds now comes from useChat context const [isUploading, setIsUploading] = useState(false) const [isDragOver, setIsDragOver] = useState(false) const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false) @@ -107,6 +104,8 @@ function ChatPage() { const inputRef = useRef(null) const fileInputRef = useRef(null) const dropdownRef = useRef(null) + const streamAbortRef = useRef(null) + const streamIdRef = useRef(0) const { addTask, isMenuOpen } = useTask() const { selectedFilter, parsedFilterData, isPanelOpen, setSelectedFilter } = useKnowledgeFilter() @@ -209,6 +208,8 @@ function ChatPage() { [endpoint]: result.response_id })) } + // Sidebar should show this conversation after upload creates it + try { refreshConversations() } catch {} } else { throw new Error(`Upload failed: ${response.status}`) @@ -351,6 +352,40 @@ function ChatPage() { inputRef.current?.focus() }, []) + // Explicitly handle external new conversation trigger + useEffect(() => { + const handleNewConversation = () => { + // Abort any in-flight streaming so it doesn't bleed into new chat + if (streamAbortRef.current) { + streamAbortRef.current.abort() + } + // Reset chat UI even if context state was already 'new' + setMessages([ + { + role: "assistant", + content: "How can I assist?", + timestamp: new Date(), + }, + ]) + setInput("") + setStreamingMessage(null) + setExpandedFunctionCalls(new Set()) + setIsFilterHighlighted(false) + setLoading(false) + } + + const handleFocusInput = () => { + inputRef.current?.focus() + } + + window.addEventListener('newConversation', handleNewConversation) + window.addEventListener('focusInput', handleFocusInput) + return () => { + window.removeEventListener('newConversation', handleNewConversation) + window.removeEventListener('focusInput', handleFocusInput) + } + }, []) + // Load conversation when conversationData changes useEffect(() => { const now = Date.now() @@ -499,6 +534,13 @@ function ChatPage() { const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow" try { + // Abort any existing stream before starting a new one + if (streamAbortRef.current) { + streamAbortRef.current.abort() + } + const controller = new AbortController() + streamAbortRef.current = controller + const thisStreamId = ++streamIdRef.current const requestBody: RequestBody = { prompt: userMessage.content, stream: true, @@ -536,6 +578,7 @@ function ChatPage() { "Content-Type": "application/json", }, body: JSON.stringify(requestBody), + signal: controller.signal, }) if (!response.ok) { @@ -554,18 +597,19 @@ function ChatPage() { let newResponseId: string | null = null // Initialize streaming message - setStreamingMessage({ - content: "", - functionCalls: [], - timestamp: new Date() - }) + if (!controller.signal.aborted && thisStreamId === streamIdRef.current) { + setStreamingMessage({ + content: "", + functionCalls: [], + timestamp: new Date() + }) + } try { while (true) { const { done, value } = await reader.read() - + if (controller.signal.aborted || thisStreamId !== streamIdRef.current) break if (done) break - buffer += decoder.decode(value, { stream: true }) // Process complete lines (JSON objects) @@ -908,11 +952,13 @@ function ChatPage() { } // Update streaming message - setStreamingMessage({ - content: currentContent, - functionCalls: [...currentFunctionCalls], - timestamp: new Date() - }) + if (!controller.signal.aborted && thisStreamId === streamIdRef.current) { + setStreamingMessage({ + content: currentContent, + functionCalls: [...currentFunctionCalls], + timestamp: new Date() + }) + } } catch (parseError) { console.warn("Failed to parse chunk:", line, parseError) @@ -932,18 +978,29 @@ function ChatPage() { timestamp: new Date() } - setMessages(prev => [...prev, finalMessage]) - setStreamingMessage(null) + if (!controller.signal.aborted && thisStreamId === streamIdRef.current) { + setMessages(prev => [...prev, finalMessage]) + setStreamingMessage(null) + } // Store the response ID for the next request for this endpoint - if (newResponseId) { + if (newResponseId && !controller.signal.aborted && thisStreamId === streamIdRef.current) { setPreviousResponseIds(prev => ({ ...prev, [endpoint]: newResponseId })) } + // Trigger sidebar refresh to include this conversation (with small delay to ensure backend has processed) + setTimeout(() => { + try { refreshConversations() } catch {} + }, 100) + } catch (error) { + // If stream was aborted (e.g., starting new conversation), do not append errors or final messages + if (streamAbortRef.current?.signal.aborted) { + return + } console.error("SSE Stream error:", error) setStreamingMessage(null) @@ -1034,6 +1091,10 @@ function ChatPage() { [endpoint]: result.response_id })) } + // Trigger sidebar refresh to include/update this conversation (with small delay to ensure backend has processed) + setTimeout(() => { + try { refreshConversations() } catch {} + }, 100) } else { console.error("Chat failed:", result.error) const errorMessage: Message = { @@ -1583,8 +1644,9 @@ function ChatPage() { // Clear filter highlight when user starts typing if (isFilterHighlighted) { - setIsFilterHighlighted(false) - } + setIsFilterHighlighted(false) + try { refreshConversations() } catch {} + } // Find if there's an @ at the start of the last word const words = newValue.split(' ') diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index cee92d3d..a5fccbef 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -8,17 +8,17 @@ import { useAuth } from "@/contexts/auth-context" import { Lock, LogIn, Loader2 } from "lucide-react" function LoginPageContent() { - const { isLoading, isAuthenticated, login } = useAuth() + const { isLoading, isAuthenticated, isNoAuthMode, login } = useAuth() const router = useRouter() const searchParams = useSearchParams() const redirect = searchParams.get('redirect') || '/chat' - // Redirect if already authenticated + // Redirect if already authenticated or in no-auth mode useEffect(() => { - if (!isLoading && isAuthenticated) { + if (!isLoading && (isAuthenticated || isNoAuthMode)) { router.push(redirect) } - }, [isLoading, isAuthenticated, router, redirect]) + }, [isLoading, isAuthenticated, isNoAuthMode, router, redirect]) if (isLoading) { return ( @@ -31,7 +31,7 @@ function LoginPageContent() { ) } - if (isAuthenticated) { + if (isAuthenticated || isNoAuthMode) { return null // Will redirect in useEffect } diff --git a/frontend/src/components/layout-wrapper.tsx b/frontend/src/components/layout-wrapper.tsx index 286f9f50..c84b5641 100644 --- a/frontend/src/components/layout-wrapper.tsx +++ b/frontend/src/components/layout-wrapper.tsx @@ -12,11 +12,14 @@ import { KnowledgeFilterPanel } from "@/components/knowledge-filter-panel" // import { DiscordLink } from "@/components/discord-link" import { useTask } from "@/contexts/task-context" import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context" +import { useAuth } from "@/contexts/auth-context" +import { Loader2 } from "lucide-react" export function LayoutWrapper({ children }: { children: React.ReactNode }) { const pathname = usePathname() const { tasks, isMenuOpen, toggleMenu } = useTask() const { selectedFilter, setSelectedFilter, isPanelOpen } = useKnowledgeFilter() + const { isLoading } = useAuth() // List of paths that should not show navigation const authPaths = ['/login', '/auth/callback'] @@ -27,6 +30,18 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) { task.status === 'pending' || task.status === 'running' || task.status === 'processing' ) + // Show loading state when backend isn't ready + if (isLoading) { + return ( +
+
+ +

Starting OpenRAG...

+
+
+ ) + } + if (isAuthPage) { // For auth pages, render without navigation return ( diff --git a/frontend/src/components/protected-route.tsx b/frontend/src/components/protected-route.tsx index 775dae7d..db746a93 100644 --- a/frontend/src/components/protected-route.tsx +++ b/frontend/src/components/protected-route.tsx @@ -10,19 +10,24 @@ interface ProtectedRouteProps { } export function ProtectedRoute({ children }: ProtectedRouteProps) { - const { isLoading, isAuthenticated } = useAuth() + const { isLoading, isAuthenticated, isNoAuthMode } = useAuth() const router = useRouter() const pathname = usePathname() - console.log("ProtectedRoute - isLoading:", isLoading, "isAuthenticated:", isAuthenticated, "pathname:", pathname) + console.log("ProtectedRoute - isLoading:", isLoading, "isAuthenticated:", isAuthenticated, "isNoAuthMode:", isNoAuthMode, "pathname:", pathname) useEffect(() => { + // In no-auth mode, allow access without authentication + if (isNoAuthMode) { + return + } + if (!isLoading && !isAuthenticated) { // Redirect to login with current path as redirect parameter const redirectUrl = `/login?redirect=${encodeURIComponent(pathname)}` router.push(redirectUrl) } - }, [isLoading, isAuthenticated, router, pathname]) + }, [isLoading, isAuthenticated, isNoAuthMode, router, pathname]) // Show loading state while checking authentication if (isLoading) { @@ -36,6 +41,11 @@ export function ProtectedRoute({ children }: ProtectedRouteProps) { ) } + // In no-auth mode, always render content + if (isNoAuthMode) { + return <>{children} + } + // Don't render anything if not authenticated (will redirect) if (!isAuthenticated) { return null diff --git a/frontend/src/components/user-nav.tsx b/frontend/src/components/user-nav.tsx index 85eb578a..85ec5ddd 100644 --- a/frontend/src/components/user-nav.tsx +++ b/frontend/src/components/user-nav.tsx @@ -15,7 +15,7 @@ import { LogIn, LogOut, User, Moon, Sun, ChevronsUpDown } from "lucide-react" import { useTheme } from "next-themes" export function UserNav() { - const { user, isLoading, isAuthenticated, login, logout } = useAuth() + const { user, isLoading, isAuthenticated, isNoAuthMode, login, logout } = useAuth() const { theme, setTheme } = useTheme() if (isLoading) { @@ -24,6 +24,20 @@ export function UserNav() { ) } + // In no-auth mode, show a simple theme switcher instead of auth UI + if (isNoAuthMode) { + return ( + + ) + } + if (!isAuthenticated) { return (