added chat pane with lot of functionality

This commit is contained in:
Mahesh Sanikommmu 2025-11-21 21:29:12 -08:00
parent 51fb9ddfc7
commit bf1f74fac4
7 changed files with 522 additions and 58 deletions

View file

@ -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<ModelId>("gemini-2.5-pro")
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
const [hoveredMessageId, setHoveredMessageId] = useState<string | null>(null)
const [chainOfThought, setChainOfThought] = useState<any[] | null>(null)
const [messageFeedback, setMessageFeedback] = useState<
Record<string, "like" | "dislike" | null>
>({})
const [expandedMemories, setExpandedMemories] = useState<string | null>(null)
const [followUpQuestions, setFollowUpQuestions] = useState<
Record<string, string[]>
>({})
const [loadingFollowUps, setLoadingFollowUps] = useState<
Record<string, boolean>
>({})
const pendingFollowUpGenerations = useRef<Set<string>>(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() {
</motion.button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 scrollbar-thin">
<div
className={cn(
"flex-1 overflow-y-auto px-4 scrollbar-thin",
dmSansClassName(),
)}
>
{messages.length === 0 && <ChatEmptyStatePlaceholder />}
<div
className={cn(
@ -237,10 +356,15 @@ export function ChatSidebar() {
copiedMessageId={copiedMessageId}
messageFeedback={messageFeedback}
expandedMemories={expandedMemories}
followUpQuestions={followUpQuestions[message.id] || []}
isLoadingFollowUps={loadingFollowUps[message.id] || false}
onCopy={handleCopyMessage}
onLike={handleLikeMessage}
onDislike={handleDislikeMessage}
onToggleMemories={handleToggleMemories}
onQuestionClick={(question) => {
setInput(question)
}}
/>
)}
</div>
@ -254,16 +378,39 @@ export function ChatSidebar() {
</div>
</div>
<div className="px-4 pb-4 pt-2">
<ChatInput
value={input}
onChange={(e) => setInput(e.target.value)}
onSend={handleSend}
onStop={stop}
onKeyDown={handleKeyDown}
isResponding={status === "submitted" || status === "streaming"}
/>
</div>
<ChatInput
value={input}
onChange={(e) => 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 ? (
<ChainOfThought
userMessage={userMessageText}
lastAgentMessage={lastAgentMessage}
/>
) : null
})()}
/>
</motion.div>
)}
</AnimatePresence>

View file

@ -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 (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"bg-[#000000] border-[#161F2C] border p-2 rounded-lg flex-shrink-0 transition-opacity",
disabled
? "opacity-50 cursor-not-allowed"
: "cursor-pointer hover:bg-[#161F2C]",
)}
>
<ArrowUpIcon className="size-5 text-white" />
</button>
)
}
export function StopButton({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="bg-[#000000] border-[#161F2C] border p-2 rounded-lg flex-shrink-0 cursor-pointer hover:bg-[#161F2C] transition-opacity"
>
<SquareIcon className="size-4 text-white fill-white" />
</button>
)
}

View file

@ -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 (
<div className="m-3 mb-0 space-y-3 relative z-10">
<div className="absolute left-[11px] top-0 bottom-0 w-[1px] bg-[#151F31] self-stretch mb-0 z-[-10]" />
<div className="flex items-start gap-3 text-[#525D6E] py-1 bg-[#01173C]">
{user && (
<Avatar className="size-[21px]">
<AvatarImage src={user?.image ?? ""} />
<AvatarFallback className="text-[10px]">
{user?.name?.charAt(0)}
</AvatarFallback>
</Avatar>
)}
<p>{userMessage}</p>
</div>
{(reasoningSteps.length > 0 || memoryResults.length > 0) && (
<div className="flex gap-3">
<div className="flex flex-col items-center mx-2 bg-[#01173C] py-1 h-fit">
<div className="size-[7px] rounded-full bg-[#151F31] shrink-0" />
</div>
<div className="flex-1 space-y-1">
{reasoningSteps.length > 0 && (
<div className="space-y-1.5">
{reasoningSteps.map((step, idx) => (
<div
key={`${step.type}-${step.state}-${idx}`}
className="text-[#525D6E]"
>
{step.message}
</div>
))}
</div>
)}
{memoryResults.length > 0 && (
<div className="grid grid-cols-2 gap-2 max-h-64 overflow-y-auto">
{memoryResults.map((result, idx) => {
const isClickable =
result.url &&
(result.url.startsWith("http://") ||
result.url.startsWith("https://"))
const content = (
<div className="">
<div className="bg-[#060D17] p-2 px-[10px] rounded-xl m-[2px]">
{result.title && (
<div className="text-xs text-[#525D6E] line-clamp-2">
{result.title}
</div>
)}
{result.content && (
<div className="text-xs text-[#525D6E]/80 line-clamp-2 mt-1">
{result.content}
</div>
)}
{result.url && (
<div className="text-xs text-[#525D6E] mt-1 truncate">
{result.url}
</div>
)}
</div>
{result.score && (
<div className="flex justify-center p-1">
<div
className={cn(
"text-[10px] inline-block bg-clip-text text-transparent font-medium",
dmSansClassName(),
)}
style={{
backgroundImage:
"var(--grad-1, linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%))",
}}
>
Relevancy score: {(result.score * 100).toFixed(1)}%
</div>
</div>
)}
</div>
)
if (isClickable) {
return (
<a
className="block p-2 bg-[#0C1829]/50 rounded-md border border-[#525D6E]/20 hover:bg-[#0C1829]/70 transition-colors cursor-pointer"
href={result.url}
key={result.documentId || idx}
rel="noopener noreferrer"
target="_blank"
>
{content}
</a>
)
}
return (
<div
className={cn("bg-[#0C1829] rounded-xl")}
key={result.documentId || idx}
>
{content}
</div>
)
})}
</div>
)}
</div>
</div>
)}
</div>
)
}

View file

@ -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<HTMLTextAreaElement>(null)
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
@ -40,16 +47,59 @@ export default function ChatInput({
}
return (
<div className="bg-[#01173C] rounded-xl">
<div className=" p-3 pr-4 flex items-center justify-between">
<motion.div
className={cn("bg-[#01173C] relative")}
animate={{
padding: isExpanded ? "0.5rem" : "0",
margin: isExpanded ? "0" : "0.5rem",
borderRadius: isExpanded ? "0 0 12px 12px" : "12px",
}}
transition={{
duration: 0.3,
ease: "easeOut",
}}
>
<div
className={cn(
"absolute bottom-full left-0 right-0 overflow-hidden transition-all duration-300 ease-out bg-[#01173C]",
isExpanded
? "max-h-[80vh] opacity-100 overflow-y-auto pt-1.5 pb-2 rounded-t-xl px-2"
: "max-h-0 opacity-0",
)}
style={{
zIndex: isExpanded ? 50 : 0,
}}
>
{isExpanded && (
<div className="absolute top-0 left-0 right-0 h-10 bg-gradient-to-b from-[#01173C] via-[#01173C]/50 to-transparent pointer-events-none z-10" />
)}
{chainOfThoughtComponent}
</div>
<button
type="button"
className={cn(
"w-full p-3 pr-4 flex items-center justify-between cursor-pointer bg-transparent border-0 text-left",
!chainOfThoughtComponent && "disabled:cursor-not-allowed",
)}
onClick={() => {
setIsExpanded(!isExpanded)
}}
>
<div className="flex items-center gap-2">
<NovaOrb size={24} className="!blur-none z-10" />
<p className={cn("text-[#525D6E]", dmSansClassName())}>
Waiting for input...
{activeStatus || "Waiting for input..."}
</p>
</div>
<ChevronUpIcon className="size-4 text-[#525D6E]" />
</div>
{chainOfThoughtComponent && (
<ChevronUpIcon
className={cn(
"size-4 text-[#525D6E] transition-transform duration-300",
isExpanded && "rotate-180",
)}
/>
)}
</button>
<div
className={cn(
"flex items-end gap-2 bg-[#070E1B] rounded-xl p-2 border-[#52596633] border focus-within:outline-[#525D6EB2] focus-within:outline-1 transition-all duration-200",
@ -75,42 +125,6 @@ export default function ChatInput({
)}
</div>
</div>
</div>
)
}
function SendButton({
onClick,
disabled,
}: {
onClick: () => void
disabled: boolean
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"bg-[#000000] border-[#161F2C] border p-2 rounded-lg flex-shrink-0 transition-opacity",
disabled
? "opacity-50 cursor-not-allowed"
: "cursor-pointer hover:bg-[#161F2C]",
)}
>
<ArrowUpIcon className="size-5 text-white" />
</button>
)
}
function StopButton({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="bg-[#000000] border-[#161F2C] border p-2 rounded-lg flex-shrink-0 cursor-pointer hover:bg-[#161F2C] transition-opacity"
>
<SquareIcon className="size-4 text-white fill-white" />
</button>
</motion.div>
)
}

View file

@ -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<string, "like" | "dislike" | null>
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
})}
<FollowUpQuestions
questions={followUpQuestions}
isLoading={isLoadingFollowUps}
onQuestionClick={onQuestionClick || (() => {})}
/>
</div>
</div>
<MessageActions

View file

@ -0,0 +1,56 @@
"use client"
import { ArrowRightIcon } from "lucide-react"
import { cn } from "@lib/utils"
interface FollowUpQuestionsProps {
questions: string[]
onQuestionClick: (question: string) => void
isLoading?: boolean
}
export function FollowUpQuestions({
questions,
onQuestionClick,
isLoading = false,
}: FollowUpQuestionsProps) {
if (isLoading) {
return (
<div className="mt-3 flex flex-col gap-2">
<div
key="skeleton-0"
className="h-4 w-28 animate-pulse rounded-full bg-white/10"
/>
<div
key="skeleton-1"
className="h-4 w-36 animate-pulse rounded-full bg-white/10"
/>
</div>
)
}
if (questions.length === 0) {
return null
}
return (
<div className="flex flex-wrap mt-2 gap-3">
<div className="text-xs">Follow up questions:</div>
<div className="flex flex-wrap">
{questions.map((question) => (
<button
key={question}
type="button"
onClick={() => onQuestionClick(question)}
className={cn(
"group flex items-center gap-1.5 rounded-full py-1 text-sm text-[#267BF1] transition-all hover:underline cursor-pointer text-start",
)}
>
<ArrowRightIcon className="size-3.5" />
<span>{question}</span>
</button>
))}
</div>
</div>
)
}

View file

@ -84,8 +84,19 @@ export function RelatedMemories({
)}
</div>
{result.score && (
<div className="text-xs text-white/50 mt-1 p-1 text-center">
Relevance Score: {(result.score * 100).toFixed(1)}%
<div className="flex justify-center p-1">
<div
className={cn(
"text-[10px] inline-block bg-clip-text text-transparent font-medium",
dmSansClassName(),
)}
style={{
backgroundImage:
"var(--grad-1, linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%))",
}}
>
Relevancy score: {(result.score * 100).toFixed(1)}%
</div>
</div>
)}
</div>