Correspondents/src/components/chat-interface.tsx
Nicholai 5305c1839c UI refinements: button positioning, message formatting, scrollbar, and animations
- 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
2025-11-15 07:17:28 -07:00

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>
)
}