- Move new chat button to left side, bookmark button stays on right - Add max-width constraint (75%) to user messages with proper text wrapping - Remove right-align text from user message frames (keep bubbles on right) - Add overflow handling for code blocks in messages - Change scrollbar color from orange to gray in light and dark modes - Fix pill loading animation flicker by initializing pinnedAgents from localStorage - Add 0.2s base delay to pill animations for staggered reveal - Improve Create new button animation: longer duration (0.6s), bouncy scale sequence, easeInOut easing
867 lines
35 KiB
TypeScript
867 lines
35 KiB
TypeScript
"use client"
|
|
|
|
import type React from "react"
|
|
|
|
import { useState, useRef, useEffect } from "react"
|
|
import { motion, AnimatePresence } from "framer-motion"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Send, Loader2, SquarePen, Paperclip, Copy, X, ChevronDown, Bookmark } from "lucide-react"
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu"
|
|
import { MarkdownRenderer } from "./markdown-renderer"
|
|
import { AgentForgeCard } from "./agent-forge-card"
|
|
import { PinnedAgentsDrawer } from "./pinned-agents-drawer"
|
|
import type { Message, Agent, AgentPackagePayload, PinnedAgent, ToolCall } from "@/lib/types"
|
|
import { cn } from "@/lib/utils"
|
|
import { useFlags } from "@/lib/use-flags"
|
|
|
|
interface ChatInterfaceProps {
|
|
agent: Agent
|
|
agents: Agent[]
|
|
onAgentSelected: (agent: Agent) => void
|
|
isAgentsLoading: boolean
|
|
}
|
|
|
|
export function ChatInterface({
|
|
agent,
|
|
agents,
|
|
onAgentSelected,
|
|
isAgentsLoading,
|
|
}: ChatInterfaceProps) {
|
|
const heroGreeting = "hello, user"
|
|
const [messages, setMessages] = useState<Message[]>([])
|
|
const [input, setInput] = useState("")
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [sessionId, setSessionId] = useState<string>("")
|
|
const [selectedImages, setSelectedImages] = useState<string[]>([])
|
|
const [composerAgentId, setComposerAgentId] = useState<string | null>(null)
|
|
const [textareaHeight, setTextareaHeight] = useState<number>(32)
|
|
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
|
|
const { flags } = useFlags()
|
|
const [agentPackage, setAgentPackage] = useState<AgentPackagePayload | null>(null)
|
|
const [showPinnedDrawer, setShowPinnedDrawer] = useState(false)
|
|
const [pinnedAgents, setPinnedAgents] = useState<PinnedAgent[]>(() => {
|
|
// Initialize from localStorage to avoid flicker on mount
|
|
if (typeof window !== "undefined") {
|
|
const stored = localStorage.getItem("pinned-agents")
|
|
if (stored) {
|
|
try {
|
|
return JSON.parse(stored)
|
|
} catch (error) {
|
|
console.error("Failed to parse pinned agents:", error)
|
|
return []
|
|
}
|
|
}
|
|
}
|
|
return []
|
|
})
|
|
const [morganAnimating, setMorganAnimating] = useState(false)
|
|
|
|
useEffect(() => {
|
|
// Use agent-specific session ID: chat-session-{agentId}
|
|
const sessionKey = `chat-session-${agent.id}`
|
|
let existingSessionId = localStorage.getItem(sessionKey)
|
|
|
|
if (!existingSessionId) {
|
|
// Generate new sessionID using timestamp and random string
|
|
existingSessionId = `session-${agent.id}-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`
|
|
localStorage.setItem(sessionKey, existingSessionId)
|
|
}
|
|
|
|
setSessionId(existingSessionId)
|
|
|
|
// Load existing messages for this agent
|
|
const messagesKey = `chat-messages-${agent.id}`
|
|
const savedMessages = localStorage.getItem(messagesKey)
|
|
if (savedMessages) {
|
|
try {
|
|
const parsed = JSON.parse(savedMessages)
|
|
// Ensure timestamps are Date objects
|
|
const messages = parsed.map((msg: any) => ({
|
|
...msg,
|
|
timestamp: new Date(msg.timestamp),
|
|
}))
|
|
setMessages(messages)
|
|
} catch (err) {
|
|
console.error("[chat] Failed to load saved messages:", err)
|
|
}
|
|
}
|
|
}, [agent.id])
|
|
|
|
useEffect(() => {
|
|
if (messagesContainerRef.current) {
|
|
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight
|
|
}
|
|
}, [messages, isLoading])
|
|
|
|
|
|
// Update textarea height based on content
|
|
useEffect(() => {
|
|
if (inputRef.current) {
|
|
const element = inputRef.current
|
|
element.style.height = "auto"
|
|
const newHeight = Math.min(element.scrollHeight, 224)
|
|
setTextareaHeight(newHeight)
|
|
}
|
|
}, [input])
|
|
|
|
// Save messages to localStorage whenever they change
|
|
useEffect(() => {
|
|
const messagesKey = `chat-messages-${agent.id}`
|
|
localStorage.setItem(messagesKey, JSON.stringify(messages))
|
|
}, [messages, agent.id])
|
|
|
|
useEffect(() => {
|
|
if (inputRef.current) {
|
|
inputRef.current.style.height = "auto"
|
|
inputRef.current.style.height = Math.min(inputRef.current.scrollHeight, 160) + "px"
|
|
}
|
|
}, [input])
|
|
|
|
useEffect(() => {
|
|
if (messages.length > 0 && composerAgentId !== agent.id) {
|
|
setComposerAgentId(agent.id)
|
|
}
|
|
}, [messages.length, agent.id])
|
|
|
|
// Handle image file selection
|
|
const handleImageSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.currentTarget.files
|
|
if (!files) return
|
|
|
|
const newImages: string[] = []
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i]
|
|
// Only accept image files
|
|
if (!file.type.startsWith("image/")) {
|
|
console.warn("[chat] Skipping non-image file:", file.name)
|
|
continue
|
|
}
|
|
|
|
try {
|
|
const base64 = await fileToBase64(file)
|
|
newImages.push(base64)
|
|
} catch (err) {
|
|
console.error("[chat] Failed to convert image:", err)
|
|
}
|
|
}
|
|
|
|
setSelectedImages((prev) => [...prev, ...newImages])
|
|
// Reset file input
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = ""
|
|
}
|
|
}
|
|
|
|
// Convert file to base64 string
|
|
const fileToBase64 = (file: File): Promise<string> => {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader()
|
|
reader.readAsDataURL(file)
|
|
reader.onload = () => resolve(reader.result as string)
|
|
reader.onerror = reject
|
|
})
|
|
}
|
|
|
|
// Remove selected image
|
|
const removeImage = (index: number) => {
|
|
setSelectedImages((prev) => prev.filter((_, i) => i !== index))
|
|
}
|
|
|
|
const sendMessage = async (e?: React.FormEvent) => {
|
|
if (e) {
|
|
e.preventDefault()
|
|
}
|
|
|
|
if (!input.trim() || isLoading) return
|
|
|
|
const userMessage: Message = {
|
|
id: Date.now().toString(),
|
|
role: "user",
|
|
content: input.trim(),
|
|
timestamp: new Date(),
|
|
images: selectedImages.length > 0 ? selectedImages : undefined,
|
|
}
|
|
|
|
setMessages((prev) => [...prev, userMessage])
|
|
setInput("")
|
|
setSelectedImages([])
|
|
setIsLoading(true)
|
|
|
|
try {
|
|
const response = await fetch("/api/chat", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
message: userMessage.content,
|
|
timestamp: userMessage.timestamp.toISOString(),
|
|
sessionId: sessionId,
|
|
agentId: agent.id,
|
|
images: selectedImages.length > 0 ? selectedImages : undefined,
|
|
}),
|
|
})
|
|
|
|
const data = (await response.json()) as {
|
|
error?: string
|
|
hint?: string
|
|
response?: string
|
|
message?: string
|
|
toolCall?: ToolCall
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const errorMessage: Message = {
|
|
id: (Date.now() + 1).toString(),
|
|
role: "assistant",
|
|
content: data.error || "Failed to communicate with the webhook.",
|
|
timestamp: new Date(),
|
|
isError: true,
|
|
hint: data.hint,
|
|
}
|
|
setMessages((prev) => [...prev, errorMessage])
|
|
} else {
|
|
// Check if this is a tool call (e.g., agent package creation)
|
|
if (data.toolCall && data.toolCall.name === "create_agent_package") {
|
|
const payload = data.toolCall.payload as AgentPackagePayload
|
|
setAgentPackage(payload)
|
|
// Don't add a regular message, the AgentForgeCard will be rendered instead
|
|
} else {
|
|
const assistantMessage: Message = {
|
|
id: (Date.now() + 1).toString(),
|
|
role: "assistant",
|
|
content: data.response || data.message || JSON.stringify(data),
|
|
timestamp: new Date(),
|
|
}
|
|
setMessages((prev) => [...prev, assistantMessage])
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("[v0] Error sending message:", error)
|
|
|
|
const errorMessage: Message = {
|
|
id: (Date.now() + 1).toString(),
|
|
role: "assistant",
|
|
content: "Sorry, I encountered an error processing your message. Please try again.",
|
|
timestamp: new Date(),
|
|
isError: true,
|
|
}
|
|
|
|
setMessages((prev) => [...prev, errorMessage])
|
|
} finally {
|
|
setIsLoading(false)
|
|
inputRef.current?.focus()
|
|
}
|
|
}
|
|
|
|
const startNewChat = () => {
|
|
// Clear all messages
|
|
setMessages([])
|
|
// Generate new sessionID for this agent
|
|
const newSessionId = `session-${agent.id}-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`
|
|
setSessionId(newSessionId)
|
|
const sessionKey = `chat-session-${agent.id}`
|
|
localStorage.setItem(sessionKey, newSessionId)
|
|
// Clear input and images
|
|
setInput("")
|
|
setSelectedImages([])
|
|
setComposerAgentId(null)
|
|
// Focus input
|
|
inputRef.current?.focus()
|
|
}
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
sendMessage()
|
|
}
|
|
}
|
|
|
|
const handleCopyMessage = async (id: string, content: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(content)
|
|
setCopiedMessageId(id)
|
|
setTimeout(() => {
|
|
setCopiedMessageId((current) => (current === id ? null : current))
|
|
}, 1200)
|
|
} catch (error) {
|
|
console.error("[chat] Failed to copy message", error)
|
|
}
|
|
}
|
|
|
|
// Handle agent package actions
|
|
const handleUseAgentNow = async (agentId: string) => {
|
|
if (!agentPackage) return
|
|
|
|
// Register the agent with the backend
|
|
try {
|
|
const response = await fetch("/api/agents/create", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
agentId,
|
|
systemPrompt: agentPackage.systemPrompt,
|
|
metadata: {
|
|
displayName: agentPackage.displayName,
|
|
summary: agentPackage.summary,
|
|
tags: agentPackage.tags,
|
|
recommendedIcon: agentPackage.hints?.recommendedIcon,
|
|
whenToUse: agentPackage.hints?.whenToUse,
|
|
},
|
|
}),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
console.error("Failed to register agent")
|
|
return
|
|
}
|
|
|
|
// Create a temporary agent object and switch to it
|
|
const customAgent: Agent = {
|
|
id: agentId,
|
|
name: agentPackage.displayName,
|
|
description: agentPackage.summary,
|
|
webhookUrl: "", // Will be handled by custom webhook
|
|
}
|
|
|
|
onAgentSelected(customAgent)
|
|
setAgentPackage(null)
|
|
|
|
// Add a timeline marker
|
|
const marker: Message = {
|
|
id: Date.now().toString(),
|
|
role: "assistant",
|
|
content: `✓ Now chatting with **${agentPackage.displayName}**`,
|
|
timestamp: new Date(),
|
|
}
|
|
setMessages((prev) => [...prev, marker])
|
|
} catch (error) {
|
|
console.error("Error registering agent:", error)
|
|
}
|
|
}
|
|
|
|
const handlePinAgent = (pkg: AgentPackagePayload, note?: string) => {
|
|
const pinnedAgent: PinnedAgent = {
|
|
agentId: pkg.agentId,
|
|
displayName: pkg.displayName,
|
|
summary: pkg.summary,
|
|
tags: pkg.tags,
|
|
recommendedIcon: pkg.hints?.recommendedIcon,
|
|
whenToUse: pkg.hints?.whenToUse,
|
|
systemPrompt: pkg.systemPrompt,
|
|
pinnedAt: new Date().toISOString(),
|
|
note,
|
|
}
|
|
|
|
// Add to pinned agents in localStorage
|
|
const stored = localStorage.getItem("pinned-agents")
|
|
const existing = stored ? JSON.parse(stored) : []
|
|
const updated = [...existing, pinnedAgent]
|
|
localStorage.setItem("pinned-agents", JSON.stringify(updated))
|
|
|
|
// Show confirmation
|
|
const confirmation: Message = {
|
|
id: Date.now().toString(),
|
|
role: "assistant",
|
|
content: `✓ **${pkg.displayName}** pinned for later use`,
|
|
timestamp: new Date(),
|
|
}
|
|
setMessages((prev) => [...prev, confirmation])
|
|
setAgentPackage(null)
|
|
}
|
|
|
|
const handleShareAgent = async (agentId: string) => {
|
|
if (!agentPackage) return
|
|
|
|
// Create a shareable link or copy agent ID
|
|
const shareText = `Check out this custom agent: ${agentPackage.displayName}\nAgent ID: ${agentId}`
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(shareText)
|
|
// Could also generate a deep link here
|
|
} catch (error) {
|
|
console.error("Failed to copy share link:", error)
|
|
}
|
|
}
|
|
|
|
const handleSelectPinnedAgent = async (pinnedAgent: PinnedAgent) => {
|
|
// Register with backend if not already registered
|
|
try {
|
|
await fetch("/api/agents/create", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
agentId: pinnedAgent.agentId,
|
|
systemPrompt: pinnedAgent.systemPrompt,
|
|
metadata: {
|
|
displayName: pinnedAgent.displayName,
|
|
summary: pinnedAgent.summary,
|
|
tags: pinnedAgent.tags,
|
|
recommendedIcon: pinnedAgent.recommendedIcon,
|
|
whenToUse: pinnedAgent.whenToUse,
|
|
},
|
|
}),
|
|
})
|
|
} catch (error) {
|
|
console.error("Error registering pinned agent:", error)
|
|
}
|
|
|
|
// Switch to this agent
|
|
const customAgent: Agent = {
|
|
id: pinnedAgent.agentId,
|
|
name: pinnedAgent.displayName,
|
|
description: pinnedAgent.summary,
|
|
webhookUrl: "",
|
|
}
|
|
|
|
onAgentSelected(customAgent)
|
|
}
|
|
|
|
const handleComposerAgentSelect = (entry: Agent) => {
|
|
setComposerAgentId(entry.id)
|
|
onAgentSelected(entry)
|
|
}
|
|
|
|
const canSwitchAgents = agents.length > 0 && !isAgentsLoading
|
|
const hasMessages = messages.length > 0
|
|
const dropdownSelectedId = composerAgentId ?? (hasMessages ? agent.id : null)
|
|
const dropdownAgentEntry = dropdownSelectedId
|
|
? agents.find((entry) => entry.id === dropdownSelectedId) ?? agent
|
|
: null
|
|
const dropdownLabel = dropdownAgentEntry ? dropdownAgentEntry.name : "Select a correspondent"
|
|
const highlightAgentDropdown = !dropdownSelectedId && !hasMessages
|
|
|
|
return (
|
|
<div className="relative h-full">
|
|
{/* Pinned agents drawer - rendered first so it's behind the chat panel */}
|
|
<PinnedAgentsDrawer
|
|
isOpen={showPinnedDrawer}
|
|
onClose={() => setShowPinnedDrawer(false)}
|
|
onSelectAgent={handleSelectPinnedAgent}
|
|
/>
|
|
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 35 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.85, ease: "easeOut" }}
|
|
className="chat-panel relative z-20 flex h-full w-full flex-col overflow-visible rounded-[2.5rem] bg-gradient-to-b from-white/0 via-white/15 to-white/45 px-4 py-8 shadow-[0_15px_35px_rgba(45,45,45,0.1),0_0_0_1px_rgba(255,255,255,0.25)_inset,0_15px_25px_rgba(255,255,255,0.12)_inset] backdrop-blur-xl dark:bg-gradient-to-b dark:from-transparent dark:via-white/5 dark:to-white/20 dark:shadow-[0_12px_25px_rgba(0,0,0,0.35),0_0_0_1px_rgba(255,255,255,0.06)_inset,0_12px_20px_rgba(255,255,255,0.04)_inset] sm:px-8 sm:py-10"
|
|
>
|
|
<div className="mb-4 flex items-center gap-2">
|
|
{messages.length > 0 && (
|
|
<Button
|
|
onClick={startNewChat}
|
|
variant="ghost"
|
|
size="icon"
|
|
className="group h-11 w-11 rounded-2xl border border-white/25 bg-white/15 text-white shadow-[0_2px_6px_rgba(0,0,0,0.12)] backdrop-blur transition hover:bg-white/25"
|
|
title="Start a fresh conversation"
|
|
>
|
|
<SquarePen className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
<div className="ml-auto flex gap-2">
|
|
<Button
|
|
onClick={() => setShowPinnedDrawer(!showPinnedDrawer)}
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn(
|
|
"group h-11 w-11 rounded-2xl border border-white/25 text-white shadow-[0_2px_6px_rgba(0,0,0,0.12)] backdrop-blur transition",
|
|
showPinnedDrawer
|
|
? "bg-white/25 border-white/40"
|
|
: "bg-white/15 hover:bg-white/30 hover:border-white/40"
|
|
)}
|
|
title={showPinnedDrawer ? "Close pinned agents" : "View pinned agents"}
|
|
>
|
|
<Bookmark className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
ref={messagesContainerRef}
|
|
className={cn(
|
|
"mobile-feed px-1 pt-4 sm:px-0",
|
|
hasMessages ? "flex-1 overflow-y-auto pb-10" : "pb-6"
|
|
)}
|
|
>
|
|
<div className="mx-auto max-w-[52rem] space-y-10 px-2 sm:px-4">
|
|
<AnimatePresence mode="wait">
|
|
{hasMessages ? (
|
|
<motion.div
|
|
key="conversation"
|
|
initial={{ opacity: 0, y: 30 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -20 }}
|
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
|
className="space-y-10"
|
|
>
|
|
{messages.map((message) => {
|
|
const isUser = message.role === "user"
|
|
return (
|
|
<motion.div
|
|
key={message.id}
|
|
layout
|
|
initial={{ opacity: 0, y: 15 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.35, ease: "easeOut" }}
|
|
className={cn("message-frame flex flex-col gap-3", isUser ? "items-end" : "")}
|
|
>
|
|
{isUser ? (
|
|
<div className="message-bubble user">
|
|
<MarkdownRenderer content={message.content} tone="bubble" />
|
|
</div>
|
|
) : message.isError ? (
|
|
<div className="text-sm font-medium text-destructive">
|
|
<p className="whitespace-pre-wrap break-words leading-relaxed">{message.content}</p>
|
|
</div>
|
|
) : (
|
|
<div className="relative text-sm text-charcoal dark:text-foreground">
|
|
<MarkdownRenderer content={message.content} />
|
|
<div className="mt-4 flex items-center justify-end gap-3 border-t border-white/10 pt-3 opacity-50 transition hover:opacity-100">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleCopyMessage(message.id, message.content)}
|
|
className={`inline-flex h-7 w-7 items-center justify-center rounded border border-white/20 bg-white/8 text-white/70 shadow-[0_2px_5px_rgba(0,0,0,0.07)] backdrop-blur transition-transform duration-150 hover:bg-white/18 ${
|
|
copiedMessageId === message.id ? "scale-90 bg-white/20 text-white" : ""
|
|
}`}
|
|
aria-label="Copy response"
|
|
>
|
|
<Copy className="h-2.5 w-2.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{message.hint && (
|
|
<div className="rounded-lg border border-accent/60 bg-accent/40 px-3 py-2 text-xs text-charcoal">
|
|
{message.hint}
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
)
|
|
})}
|
|
|
|
{isLoading && (
|
|
<div className="message-frame flex flex-col gap-3">
|
|
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
|
<span className="uppercase tracking-[0.25em] text-white/70">Correspondent</span>
|
|
<span className="relative flex h-3 w-24 overflow-hidden rounded-full bg-white/10">
|
|
<span className="absolute inset-y-0 w-1/2 animate-[shimmer_1.4s_infinite] bg-white/40"></span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{agentPackage && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="message-frame"
|
|
>
|
|
<AgentForgeCard
|
|
payload={agentPackage}
|
|
onUseNow={handleUseAgentNow}
|
|
onPin={handlePinAgent}
|
|
onShare={handleShareAgent}
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
</motion.div>
|
|
) : (
|
|
<motion.div
|
|
key="empty-state"
|
|
initial={{ opacity: 0, y: 60 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -20 }}
|
|
transition={{ duration: 0.65, ease: "easeOut" }}
|
|
className="flex min-h-[40vh] flex-col items-center justify-center gap-6 text-center"
|
|
>
|
|
<div className="text-center">
|
|
<h1 className="font-heading text-[3.5rem] lowercase tracking-tight text-white/85 drop-shadow-[0_12px_30px_rgba(0,0,0,0.4)] sm:text-[7rem]">
|
|
{heroGreeting.split("").map((char, index) => (
|
|
<motion.span
|
|
key={`${char}-${index}`}
|
|
initial={{ opacity: 0, y: 18 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.15 + index * 0.05, duration: 0.35, ease: "easeOut" }}
|
|
className="inline-block"
|
|
>
|
|
{char === " " ? "\u00A0" : char}
|
|
</motion.span>
|
|
))}
|
|
</h1>
|
|
</div>
|
|
|
|
<div className="w-full max-w-3xl space-y-4">
|
|
<p className="text-sm uppercase tracking-[0.35em] text-white/80">
|
|
Select a correspondent to begin
|
|
</p>
|
|
{agents.length > 0 ? (
|
|
<div className="flex flex-wrap items-center justify-center gap-3 mx-auto max-w-2xl">
|
|
{agents.map((entry, index) => {
|
|
const isActive = dropdownSelectedId === entry.id
|
|
return (
|
|
<motion.button
|
|
key={entry.id}
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ delay: 0.2 + index * 0.06, duration: 0.4, ease: "easeOut" }}
|
|
onClick={() => handleComposerAgentSelect(entry)}
|
|
className={cn(
|
|
"rounded-full px-4 py-2 text-[0.65rem] uppercase tracking-[0.35em] transition relative overflow-hidden group backdrop-blur-sm shadow-[0_2px_8px_rgba(0,0,0,0.15)]",
|
|
isActive
|
|
? "bg-white/20 text-white shadow-[0_4px_12px_rgba(0,0,0,0.25)]"
|
|
: "bg-white/8 text-white/70 hover:bg-white/15 hover:text-white"
|
|
)}
|
|
>
|
|
{!isActive && (
|
|
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 bg-gradient-to-r from-white/10 via-white/5 to-transparent" />
|
|
)}
|
|
<span className="relative">{entry.name}</span>
|
|
</motion.button>
|
|
)
|
|
})}
|
|
{pinnedAgents.slice(0, 2).map((pinnedAgent, index) => {
|
|
const isActive = dropdownSelectedId === pinnedAgent.agentId
|
|
return (
|
|
<motion.button
|
|
key={pinnedAgent.agentId}
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ delay: 0.2 + (agents.length + index) * 0.06, duration: 0.4, ease: "easeOut" }}
|
|
onClick={() => handleComposerAgentSelect({
|
|
id: pinnedAgent.agentId,
|
|
name: pinnedAgent.displayName,
|
|
description: pinnedAgent.summary || "",
|
|
webhookUrl: "" // Custom agents use dynamic routing
|
|
} as Agent)}
|
|
className={cn(
|
|
"rounded-full px-4 py-2 text-[0.65rem] uppercase tracking-[0.35em] transition relative overflow-hidden group backdrop-blur-sm shadow-[0_2px_8px_rgba(0,0,0,0.15)]",
|
|
isActive
|
|
? "bg-white/15 text-white shadow-[0_4px_12px_rgba(0,0,0,0.25)]"
|
|
: "bg-white/8 text-white/70 hover:bg-white/15 hover:text-white"
|
|
)}
|
|
>
|
|
{!isActive && (
|
|
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 bg-gradient-to-r from-white/10 via-white/5 to-transparent" />
|
|
)}
|
|
<span className="relative">{pinnedAgent.recommendedIcon} {pinnedAgent.displayName}</span>
|
|
</motion.button>
|
|
)
|
|
})}
|
|
<motion.button
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={morganAnimating ? { opacity: 1, scale: [1, 1.1, 0.95, 1.05, 1] } : { opacity: 1, scale: 1 }}
|
|
transition={morganAnimating ? { duration: 0.6, ease: "easeInOut" } : { delay: 0.2 + (agents.length + pinnedAgents.length) * 0.06, duration: 0.4, ease: "easeOut" }}
|
|
onClick={() => {
|
|
setMorganAnimating(true)
|
|
setTimeout(() => {
|
|
handleComposerAgentSelect(agents.find(a => a.name === "Morgan") || agents[0])
|
|
setInput("Help me create a new custom agent")
|
|
setMorganAnimating(false)
|
|
}, 600)
|
|
}}
|
|
className="rounded-full bg-white/8 px-4 py-2 text-[0.65rem] uppercase tracking-[0.35em] text-white/70 transition relative overflow-hidden group backdrop-blur-sm hover:bg-white/15 hover:text-white shadow-[0_2px_8px_rgba(0,0,0,0.15)]"
|
|
>
|
|
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 bg-gradient-to-r from-white/10 via-white/5 to-transparent" />
|
|
<span className="relative">+ Create new</span>
|
|
</motion.button>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-white/60">No agents available yet.</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid w-full max-w-2xl gap-4 sm:grid-cols-2">
|
|
{[
|
|
"Help me brainstorm ideas for a new mobile app",
|
|
"Generate creative writing prompts for a fantasy novel",
|
|
"Suggest innovative marketing strategies for a startup",
|
|
"Create a list of unique product names for a tech company",
|
|
].map((prompt, index) => (
|
|
<button
|
|
key={prompt}
|
|
onClick={() => setInput(prompt)}
|
|
className="scroll-reveal rounded-2xl border border-border/30 bg-white/80 p-4 text-left text-sm text-charcoal shadow-sm transition hover:border-ring/60 hover:bg-white"
|
|
style={{ animationDelay: `${index * 50}ms` }}
|
|
>
|
|
{prompt}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
|
|
<motion.div
|
|
layout
|
|
className="composer-affix relative mt-auto pt-6 pb-4 transition-all duration-500"
|
|
animate={{ y: hasMessages ? 0 : -140, scale: hasMessages ? 1 : 1.05 }}
|
|
transition={{ type: "spring", stiffness: 160, damping: 24 }}
|
|
>
|
|
<form onSubmit={sendMessage} className="composer-form relative flex justify-center">
|
|
{/* Image preview section */}
|
|
{flags.IMAGE_UPLOADS_ENABLED && selectedImages.length > 0 && (
|
|
<div className="composer-images mb-3 flex flex-wrap gap-3 px-3 pt-2">
|
|
{selectedImages.map((image, index) => (
|
|
<div key={index} className="composer-image-thumb relative">
|
|
<img
|
|
src={image}
|
|
alt={`Selected ${index}`}
|
|
className="h-16 w-16 rounded-lg border border-border/40 object-cover shadow-md"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeImage(index)}
|
|
className="absolute -right-2 -top-2 flex h-5 w-5 items-center justify-center rounded-full text-white shadow-md hover:opacity-80"
|
|
style={{ backgroundColor: "var(--charcoal-ink)" }}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className={cn(
|
|
"manuscript-panel composer-panel w-[85%] max-w-2xl p-5",
|
|
"max-sm:mobile-composer max-sm:w-full max-sm:p-4"
|
|
)}
|
|
>
|
|
<div className="flex flex-col gap-4">
|
|
<motion.textarea
|
|
ref={inputRef}
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Write a note, share a hunch, or paste a brief…"
|
|
disabled={isLoading}
|
|
rows={1}
|
|
className="hide-scrollbar w-full resize-none border-0 bg-transparent text-lg text-foreground placeholder:text-muted-foreground/80 focus:outline-none"
|
|
animate={{
|
|
height: textareaHeight
|
|
}}
|
|
transition={{
|
|
height: {
|
|
type: "spring",
|
|
stiffness: 600,
|
|
damping: 35,
|
|
mass: 0.5,
|
|
}
|
|
}}
|
|
style={{
|
|
overflowY: "auto",
|
|
minHeight: "32px",
|
|
maxHeight: "224px",
|
|
}}
|
|
/>
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"composer-dropdown-trigger inline-flex max-w-[12rem] items-center gap-2 rounded-2xl border border-white/20 bg-white/30 px-3 py-2 text-left text-[0.55rem] uppercase tracking-[0.3em] shadow-[0_10px_25px_rgba(0,0,0,0.2)] backdrop-blur transition hover:bg-white/40 hover:text-white disabled:opacity-50",
|
|
highlightAgentDropdown ? "agent-picker-prompt text-white" : "text-white"
|
|
)}
|
|
disabled={!canSwitchAgents}
|
|
>
|
|
<span className="truncate text-xs font-heading normal-case tracking-normal text-white">
|
|
{dropdownLabel}
|
|
</span>
|
|
<ChevronDown className="h-3.5 w-3.5 text-white/70" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="start"
|
|
className="min-w-[12rem] rounded-2xl border border-white/15 bg-white/10 p-2 text-white shadow-[0_20px_40px_rgba(0,0,0,0.3)] backdrop-blur"
|
|
>
|
|
{isAgentsLoading ? (
|
|
<DropdownMenuItem disabled className="text-white/50">
|
|
Gathering correspondents…
|
|
</DropdownMenuItem>
|
|
) : agents.length === 0 ? (
|
|
<DropdownMenuItem disabled className="text-white/50">
|
|
No agents configured
|
|
</DropdownMenuItem>
|
|
) : (
|
|
agents.map((entry) => {
|
|
const isActive = dropdownSelectedId === entry.id
|
|
return (
|
|
<DropdownMenuItem
|
|
key={entry.id}
|
|
onClick={() => handleComposerAgentSelect(entry)}
|
|
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 text-xs transition ${
|
|
isActive
|
|
? "bg-white/15 text-white"
|
|
: "text-white/90 hover:bg-white/5 hover:text-white"
|
|
}`}
|
|
>
|
|
<span className="font-heading text-sm">{entry.name}</span>
|
|
{isActive && <span className="text-[0.55rem] uppercase tracking-[0.3em]">Active</span>}
|
|
</DropdownMenuItem>
|
|
)
|
|
})
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<div className="flex flex-wrap items-center justify-end gap-3 text-xs uppercase tracking-[0.25em] text-muted-foreground">
|
|
<Button
|
|
type="submit"
|
|
disabled={!input.trim() || isLoading}
|
|
size="icon"
|
|
className="composer-send-button group h-12 w-12 flex-shrink-0 rounded-2xl border border-white/20 bg-white/30 text-white shadow-[0_10px_25px_rgba(0,0,0,0.2)] backdrop-blur transition hover:bg-white/40 disabled:opacity-50"
|
|
>
|
|
{isLoading ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Send className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
{flags.IMAGE_UPLOADS_ENABLED && (
|
|
<>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
accept="image/*"
|
|
onChange={handleImageSelect}
|
|
className="hidden"
|
|
disabled={isLoading}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={isLoading}
|
|
className="composer-action-button h-11 w-11 rounded-2xl border border-white/20 bg-white/10 text-white/80 transition hover:bg-white/20 hover:text-white"
|
|
title="Attach image"
|
|
>
|
|
<Paperclip className="h-4 w-4" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</motion.div>
|
|
</motion.div>
|
|
</div>
|
|
)
|
|
}
|