From bf1f74fac46876a48daf16cb44e112607e81fb5f Mon Sep 17 00:00:00 2001 From: Mahesh Sanikommmu Date: Fri, 21 Nov 2025 21:29:12 -0800 Subject: [PATCH] added chat pane with lot of functionality --- apps/web/components/chat/index.tsx | 171 ++++++++++++++-- apps/web/components/chat/input/actions.tsx | 38 ++++ .../chat/input/chain-of-thought.tsx | 186 ++++++++++++++++++ .../chat/{input.tsx => input/index.tsx} | 102 +++++----- .../components/chat/message/agent-message.tsx | 12 ++ .../chat/message/follow-up-questions.tsx | 56 ++++++ .../chat/message/related-memories.tsx | 15 +- 7 files changed, 522 insertions(+), 58 deletions(-) create mode 100644 apps/web/components/chat/input/actions.tsx create mode 100644 apps/web/components/chat/input/chain-of-thought.tsx rename apps/web/components/chat/{input.tsx => input/index.tsx} (53%) create mode 100644 apps/web/components/chat/message/follow-up-questions.tsx diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx index 0c99b2b6..d439fc3d 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useEffect, useCallback } from "react" +import { useState, useEffect, useCallback, useRef } from "react" import { motion, AnimatePresence } from "motion/react" import { useChat } from "@ai-sdk/react" import { DefaultChatTransport } from "ai" @@ -22,6 +22,7 @@ import type { ModelId } from "@/lib/models" import { SuperLoader } from "../superloader" import { UserMessage } from "./message/user-message" import { AgentMessage } from "./message/agent-message" +import { ChainOfThought } from "./input/chain-of-thought" export function ChatSidebar() { const [input, setInput] = useState("") @@ -29,10 +30,18 @@ export function ChatSidebar() { const [selectedModel, setSelectedModel] = useState("gemini-2.5-pro") const [copiedMessageId, setCopiedMessageId] = useState(null) const [hoveredMessageId, setHoveredMessageId] = useState(null) + const [chainOfThought, setChainOfThought] = useState(null) const [messageFeedback, setMessageFeedback] = useState< Record >({}) const [expandedMemories, setExpandedMemories] = useState(null) + const [followUpQuestions, setFollowUpQuestions] = useState< + Record + >({}) + const [loadingFollowUps, setLoadingFollowUps] = useState< + Record + >({}) + const pendingFollowUpGenerations = useRef>(new Set()) const { selectedProject } = useProject() const { setCurrentChatId } = usePersistentChat() @@ -48,8 +57,113 @@ export function ChatSidebar() { }, }), maxSteps: 10, + onFinish: async (result) => { + if (result.message.role !== "assistant") return + + // Mark this message as needing follow-up generation + // We'll generate it after the message is fully in the messages array + if (result.message.id) { + pendingFollowUpGenerations.current.add(result.message.id) + } + }, }) + // Generate follow-up questions after assistant messages are complete + useEffect(() => { + const generateFollowUps = async () => { + // Find assistant messages that need follow-up generation + const messagesToProcess = messages.filter( + (msg) => + msg.role === "assistant" && + pendingFollowUpGenerations.current.has(msg.id) && + !followUpQuestions[msg.id] && + !loadingFollowUps[msg.id], + ) + + for (const message of messagesToProcess) { + // Get complete text from the message + const assistantText = message.parts + .filter((p) => p.type === "text") + .map((p) => p.text) + .join(" ") + .trim() + + // Only generate if we have substantial text (at least 50 chars) + // This ensures the message is complete, not just the first chunk + // Also check if status is idle to ensure streaming is complete + if ( + assistantText.length < 50 || + status === "streaming" || + status === "submitted" + ) { + continue + } + + // Mark as processing + pendingFollowUpGenerations.current.delete(message.id) + setLoadingFollowUps((prev) => ({ + ...prev, + [message.id]: true, + })) + + try { + // Get recent messages for context + const recentMessages = messages.slice(-5).map((msg) => ({ + role: msg.role, + content: msg.parts + .filter((p) => p.type === "text") + .map((p) => p.text) + .join(" "), + })) + + const response = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/follow-ups`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ + messages: recentMessages, + assistantResponse: assistantText, + }), + }, + ) + + if (response.ok) { + const data = await response.json() + if (data.questions && Array.isArray(data.questions)) { + setFollowUpQuestions((prev) => ({ + ...prev, + [message.id]: data.questions, + })) + } + } + } catch (error) { + console.error("Failed to generate follow-up questions:", error) + } finally { + setLoadingFollowUps((prev) => ({ + ...prev, + [message.id]: false, + })) + } + } + } + + // Only generate if not currently streaming or submitted + // Small delay to ensure message is fully processed + if (status !== "streaming" && status !== "submitted") { + const timeoutId = setTimeout(() => { + generateFollowUps() + }, 300) + + return () => clearTimeout(timeoutId) + } + }, [messages, followUpQuestions, loadingFollowUps, status]) + + console.log(messages) + const handleSend = () => { if (!input.trim() || status === "submitted" || status === "streaming") return @@ -197,7 +311,12 @@ export function ChatSidebar() { -
+
{messages.length === 0 && }
{ + setInput(question) + }} /> )}
@@ -254,16 +378,39 @@ export function ChatSidebar() {
-
- setInput(e.target.value)} - onSend={handleSend} - onStop={stop} - onKeyDown={handleKeyDown} - isResponding={status === "submitted" || status === "streaming"} - /> -
+ setInput(e.target.value)} + onSend={handleSend} + onStop={stop} + onKeyDown={handleKeyDown} + isResponding={status === "submitted" || status === "streaming"} + activeStatus={ + status === "submitted" + ? "Thinking..." + : status === "streaming" + ? "Structuring response..." + : "Waiting for input..." + } + chainOfThoughtComponent={(() => { + const lastUserMessage = [...messages] + .reverse() + .find((msg) => msg.role === "user") + const lastAgentMessage = [...messages] + .reverse() + .find((msg) => msg.role === "assistant") + const userMessageText = + lastUserMessage?.parts.find((part) => part.type === "text") + ?.text ?? "" + + return userMessageText ? ( + + ) : null + })()} + /> )} diff --git a/apps/web/components/chat/input/actions.tsx b/apps/web/components/chat/input/actions.tsx new file mode 100644 index 00000000..8b49ac7d --- /dev/null +++ b/apps/web/components/chat/input/actions.tsx @@ -0,0 +1,38 @@ +import { cn } from "@lib/utils" +import { ArrowUpIcon, SquareIcon } from "lucide-react" + +export function SendButton({ + onClick, + disabled, +}: { + onClick: () => void + disabled: boolean +}) { + return ( + + ) +} + +export function StopButton({ onClick }: { onClick: () => void }) { + return ( + + ) +} diff --git a/apps/web/components/chat/input/chain-of-thought.tsx b/apps/web/components/chat/input/chain-of-thought.tsx new file mode 100644 index 00000000..cbf602b2 --- /dev/null +++ b/apps/web/components/chat/input/chain-of-thought.tsx @@ -0,0 +1,186 @@ +import { useAuth } from "@lib/auth-context" +import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar" +import type { UIMessage } from "@ai-sdk/react" +import { cn } from "@lib/utils" +import { dmSansClassName } from "@/utils/fonts" + +interface MemoryResult { + documentId?: string + title?: string + content?: string + url?: string + score?: number +} + +interface ReasoningStep { + type: string + state?: string + message: string +} + +export function ChainOfThought({ + userMessage, + lastAgentMessage, +}: { + userMessage: string + lastAgentMessage: UIMessage | undefined +}) { + const { user } = useAuth() + + const reasoningSteps: ReasoningStep[] = [] + if (lastAgentMessage) { + lastAgentMessage.parts.forEach((part) => { + if (part.type === "tool-searchMemories") { + if ( + part.state === "input-available" || + part.state === "input-streaming" + ) { + reasoningSteps.push({ + type: part.type, + state: part.state, + message: "Searching memories...", + }) + } else if (part.state === "output-available") { + reasoningSteps.push({ + type: part.type, + state: part.state, + message: "Found relevant memories", + }) + } else if (part.state === "output-error") { + reasoningSteps.push({ + type: part.type, + state: part.state, + message: "Error searching memories", + }) + } + } + }) + } + + const memoryResults: MemoryResult[] = [] + if (lastAgentMessage) { + lastAgentMessage.parts.forEach((part) => { + if ( + part.type === "tool-searchMemories" && + part.state === "output-available" + ) { + const output = part.output as { results?: MemoryResult[] } | undefined + const results = Array.isArray(output?.results) ? output.results : [] + memoryResults.push(...results) + } + }) + } + + return ( +
+
+ +
+ {user && ( + + + + {user?.name?.charAt(0)} + + + )} +

{userMessage}

+
+ + {(reasoningSteps.length > 0 || memoryResults.length > 0) && ( +
+
+
+
+ +
+ {reasoningSteps.length > 0 && ( +
+ {reasoningSteps.map((step, idx) => ( +
+ {step.message} +
+ ))} +
+ )} + + {memoryResults.length > 0 && ( +
+ {memoryResults.map((result, idx) => { + const isClickable = + result.url && + (result.url.startsWith("http://") || + result.url.startsWith("https://")) + + const content = ( +
+
+ {result.title && ( +
+ {result.title} +
+ )} + {result.content && ( +
+ {result.content} +
+ )} + {result.url && ( +
+ {result.url} +
+ )} +
+ {result.score && ( +
+
+ Relevancy score: {(result.score * 100).toFixed(1)}% +
+
+ )} +
+ ) + + if (isClickable) { + return ( + + {content} + + ) + } + + return ( +
+ {content} +
+ ) + })} +
+ )} +
+
+ )} +
+ ) +} diff --git a/apps/web/components/chat/input.tsx b/apps/web/components/chat/input/index.tsx similarity index 53% rename from apps/web/components/chat/input.tsx rename to apps/web/components/chat/input/index.tsx index 2d307062..5cf211ae 100644 --- a/apps/web/components/chat/input.tsx +++ b/apps/web/components/chat/input/index.tsx @@ -1,10 +1,12 @@ "use client" -import { ArrowUpIcon, ChevronUpIcon, SquareIcon } from "lucide-react" -import NovaOrb from "../nova/nova-orb" +import { ChevronUpIcon } from "lucide-react" +import NovaOrb from "@/components/nova/nova-orb" import { cn } from "@lib/utils" import { dmSansClassName } from "@/utils/fonts" import { useRef, useState } from "react" +import { motion } from "motion/react" +import { SendButton, StopButton } from "./actions" interface ChatInputProps { value: string @@ -13,6 +15,8 @@ interface ChatInputProps { onStop: () => void onKeyDown?: (e: React.KeyboardEvent) => void isResponding?: boolean + activeStatus?: string + chainOfThoughtComponent?: React.ReactNode } export default function ChatInput({ @@ -22,8 +26,11 @@ export default function ChatInput({ onStop, onKeyDown, isResponding = false, + activeStatus, + chainOfThoughtComponent, }: ChatInputProps) { const [isMultiline, setIsMultiline] = useState(false) + const [isExpanded, setIsExpanded] = useState(false) const textareaRef = useRef(null) const handleChange = (e: React.ChangeEvent) => { @@ -40,16 +47,59 @@ export default function ChatInput({ } return ( -
-
+ +
+ {isExpanded && ( +
+ )} + {chainOfThoughtComponent} +
+
+ {chainOfThoughtComponent && ( + + )} +
-
- ) -} - -function SendButton({ - onClick, - disabled, -}: { - onClick: () => void - disabled: boolean -}) { - return ( - - ) -} - -function StopButton({ onClick }: { onClick: () => void }) { - return ( - + ) } diff --git a/apps/web/components/chat/message/agent-message.tsx b/apps/web/components/chat/message/agent-message.tsx index c46c30ac..cab3407e 100644 --- a/apps/web/components/chat/message/agent-message.tsx +++ b/apps/web/components/chat/message/agent-message.tsx @@ -4,6 +4,7 @@ import type { UIMessage } from "@ai-sdk/react" import { Streamdown } from "streamdown" import { RelatedMemories } from "./related-memories" import { MessageActions } from "./message-actions" +import { FollowUpQuestions } from "./follow-up-questions" interface AgentMessageProps { message: UIMessage @@ -13,10 +14,13 @@ interface AgentMessageProps { copiedMessageId: string | null messageFeedback: Record expandedMemories: string | null + followUpQuestions?: string[] + isLoadingFollowUps?: boolean onCopy: (messageId: string, text: string) => void onLike: (messageId: string) => void onDislike: (messageId: string) => void onToggleMemories: (messageId: string) => void + onQuestionClick?: (question: string) => void } export function AgentMessage({ @@ -27,10 +31,13 @@ export function AgentMessage({ copiedMessageId, messageFeedback, expandedMemories, + followUpQuestions = [], + isLoadingFollowUps = false, onCopy, onLike, onDislike, onToggleMemories, + onQuestionClick, }: AgentMessageProps) { const isLastAgentMessage = index === messagesLength - 1 && message.role === "assistant" @@ -78,6 +85,11 @@ export function AgentMessage({ } return null })} + {})} + />
void + isLoading?: boolean +} + +export function FollowUpQuestions({ + questions, + onQuestionClick, + isLoading = false, +}: FollowUpQuestionsProps) { + if (isLoading) { + return ( +
+
+
+
+ ) + } + + if (questions.length === 0) { + return null + } + + return ( +
+
Follow up questions:
+
+ {questions.map((question) => ( + + ))} +
+
+ ) +} diff --git a/apps/web/components/chat/message/related-memories.tsx b/apps/web/components/chat/message/related-memories.tsx index f93aeb2d..d24a1b96 100644 --- a/apps/web/components/chat/message/related-memories.tsx +++ b/apps/web/components/chat/message/related-memories.tsx @@ -84,8 +84,19 @@ export function RelatedMemories({ )}
{result.score && ( -
- Relevance Score: {(result.score * 100).toFixed(1)}% +
+
+ Relevancy score: {(result.score * 100).toFixed(1)}% +
)}