diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index c04d4827..53a464ba 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "@tanstack/react-router"; import { useQuery } from "convex/react"; import { api } from "@server/convex/_generated/api"; @@ -56,13 +56,12 @@ function ChatItemSkeleton({ delay = 0 }: { delay?: number }) { ); } -function groupChatsByTime(chats: Array) { +function groupChatsByTime(chats: Array, now: number) { const today: Array = []; const last7Days: Array = []; const last30Days: Array = []; const older: Array = []; - const now = Date.now(); const oneDayMs = 1000 * 60 * 60 * 24; for (const chat of chats) { @@ -314,8 +313,12 @@ export function AppSidebar() { ? convexUser === undefined || chatsResult === undefined : false; - const grouped = groupChatsByTime(chats); - const deleteChat = deleteChatId ? chats.find((chat) => chat._id === deleteChatId) : null; + const dayKey = new Date().toDateString(); + const grouped = useMemo(() => groupChatsByTime(chats, Date.now()), [chats, dayKey]); + const deleteChat = useMemo( + () => (deleteChatId ? chats.find((chat) => chat._id === deleteChatId) : null), + [deleteChatId, chats], + ); const handleNewChat = () => { if (isMobile) { diff --git a/apps/web/src/components/chat-interface.tsx b/apps/web/src/components/chat-interface.tsx index 3fcefaf5..e6e87e80 100644 --- a/apps/web/src/components/chat-interface.tsx +++ b/apps/web/src/components/chat-interface.tsx @@ -10,7 +10,7 @@ * - Convex persistence for chat history */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useNavigate } from "@tanstack/react-router"; import { ArrowUpIcon, BrainIcon, ChevronDownIcon, GlobeIcon, @@ -1261,6 +1261,145 @@ export function ChatInterface({ chatId }: ChatInterfaceProps) { ); } +interface ChatMessageListProps { + messages: Array<{ + id: string; + role: string; + parts?: Array>; + }>; + isLoading: boolean; + isNewChat: boolean; + onPromptSelect: (prompt: string) => void; +} + +const ChatMessageList = memo(function ChatMessageList({ + messages, + isLoading, + isNewChat, + onPromptSelect, +}: ChatMessageListProps) { + const processedMessages = useMemo(() => { + if (messages.length === 0) return []; + const streamingId = isLoading ? messages[messages.length - 1]?.id : null; + + return messages.map((message) => { + const msg = message as typeof message & { + error?: { + code: string; + message: string; + details?: string; + provider?: string; + retryable?: boolean; + }; + messageType?: "text" | "error" | "system"; + }; + + const allParts = message.parts || []; + const textParts = allParts.filter((p): p is { type: "text"; text: string } => p.type === "text"); + const fileParts = allParts.filter((p): p is { type: "file"; filename?: string; url?: string; mediaType?: string } => p.type === "file"); + + const { + steps: thinkingSteps, + isAnyStreaming: isAnyStepStreaming, + hasTextContent, + } = buildChainOfThoughtSteps(allParts); + + const textContent = textParts.map((p) => p.text).join("").trim(); + const hasReasoning = allParts.some((p) => p.type === "reasoning"); + const hasFiles = fileParts.length > 0; + const isCurrentlyStreaming = streamingId === message.id; + + const shouldSkip = + msg.messageType !== "error" && + message.role === "assistant" && + !textContent && + !hasReasoning && + !hasFiles && + !isCurrentlyStreaming; + + return { + message, + msg, + textParts, + fileParts, + thinkingSteps, + isAnyStepStreaming, + hasTextContent, + isCurrentlyStreaming, + shouldSkip, + }; + }); + }, [messages, isLoading]); + + return ( + + + {/* Mobile: extra top padding to clear hamburger menu (fixed left-3 top-3 size-11 = 12px + 44px + 8px breathing room = 64px) */} + + {messages.length === 0 && isNewChat ? ( + + ) : messages.length === 0 ? null : ( + <> + {processedMessages.map((item) => { + if (item.shouldSkip) return null; + + if (item.msg.messageType === "error" && item.msg.error) { + return ( +
+ + + + + +
+ ); + } + + return ( +
+ + + {item.thinkingSteps.length > 0 && ( + 0} + /> + )} + + {item.textParts.map((part, partIndex) => ( + + {part.text || ""} + + ))} + + {item.fileParts.map((part, partIndex) => ( + + ))} + + +
+ ); + })} + {isLoading && messages[messages.length - 1]?.role === "user" && ( + + )} + {/* Note: Errors are now shown inline as messages via InlineErrorMessage */} + + )} +
+
+ ); +}); + // Inner content component that has access to PromptInputProvider context interface ChatInterfaceContentProps { chatId: string | null; @@ -1324,15 +1463,16 @@ function ChatInterfaceContent({ }, [textareaRef]); // Handler for StartScreen prompt selection - populates input and focuses + const setInput = controller.textInput.setInput; const onPromptSelect = useCallback( (prompt: string) => { - controller.textInput.setInput(prompt); + setInput(prompt); // Focus the textarea after setting the value setTimeout(() => { textareaRef.current?.focus(); }, 0); }, - [controller.textInput, textareaRef], + [setInput, textareaRef], ); // Wrap handleSubmit to clear the draft after successful submission @@ -1348,130 +1488,12 @@ function ChatInterfaceContent({ return (
- {/* Messages area - using AI Elements Conversation */} - - - {/* Mobile: extra top padding to clear hamburger menu (fixed left-3 top-3 size-11 = 12px + 44px + 8px breathing room = 64px) */} - - {messages.length === 0 && isNewChat ? ( - - ) : messages.length === 0 ? null : ( - <> - {messages.map((message) => { - // Cast to include our custom error fields - const msg = message as typeof message & { - error?: { - code: string; - message: string; - details?: string; - provider?: string; - retryable?: boolean; - }; - messageType?: "text" | "error" | "system"; - }; - - // Render error messages with special styling (like T3.chat) - if (msg.messageType === "error" && msg.error) { - return ( -
- - - - - -
- ); - } - - // Skip rendering assistant messages with no meaningful content - // This handles cases where AI SDK creates empty messages on error - const allParts = message.parts || []; - const textContent = allParts - .filter((p): p is { type: "text"; text: string } => p.type === "text") - .map((p) => p.text) - .join("") - .trim(); - const hasReasoning = allParts.some((p) => p.type === "reasoning"); - const hasFiles = allParts.some((p) => p.type === "file"); - - // Skip empty assistant messages (no text, reasoning, or files) - // But don't skip during streaming (messages are being built) - const isCurrentlyStreaming = - isLoading && messages[messages.length - 1]?.id === message.id; - if ( - message.role === "assistant" && - !textContent && - !hasReasoning && - !hasFiles && - !isCurrentlyStreaming - ) { - return null; - } - - // Regular message rendering - // Use buildChainOfThoughtSteps to process parts IN ORDER - // This preserves the exact stream order and merges consecutive reasoning - - const textParts = allParts.filter((p) => p.type === "text") as unknown as Array<{ - type: "text"; - text: string; - }>; - const fileParts = allParts.filter((p) => p.type === "file") as unknown as Array<{ - type: "file"; - filename?: string; - url?: string; - mediaType?: string; - }>; - - // Build thinking steps from reasoning and tool parts - const { - steps: thinkingSteps, - isAnyStreaming: isAnyStepStreaming, - hasTextContent, - } = buildChainOfThoughtSteps(allParts); - - return ( -
- - - {thinkingSteps.length > 0 && ( - 0} - /> - )} - - {textParts.map((part, partIndex) => ( - - {part.text || ""} - - ))} - - {fileParts.map((part, partIndex) => ( - - ))} - - -
- ); - })} - {isLoading && messages[messages.length - 1]?.role === "user" && ( - - )} - {/* Note: Errors are now shown inline as messages via InlineErrorMessage */} - - )} -
-
+
diff --git a/apps/web/src/providers/index.tsx b/apps/web/src/providers/index.tsx index 93f01cc0..2a6276e3 100644 --- a/apps/web/src/providers/index.tsx +++ b/apps/web/src/providers/index.tsx @@ -11,7 +11,30 @@ import { ThemeProvider } from "./theme-provider"; import { PostHogProvider } from "./posthog"; if (typeof window !== "undefined") { - prefetchModels(); + const schedulePrefetch = () => { + const connection = (navigator as Navigator & { + connection?: { effectiveType?: string; saveData?: boolean }; + }).connection; + if (connection?.saveData) return; + if (connection?.effectiveType && /2g/.test(connection.effectiveType)) return; + + const run = () => prefetchModels(); + const requestIdle = (window as Window & { + requestIdleCallback?: (cb: () => void, options?: { timeout: number }) => number; + }).requestIdleCallback; + + if (requestIdle) { + requestIdle(run, { timeout: 2000 }); + } else { + setTimeout(run, 1500); + } + }; + + if (document.readyState === "complete") { + schedulePrefetch(); + } else { + window.addEventListener("load", schedulePrefetch, { once: true }); + } } const queryClient = new QueryClient({