mirror of https://github.com/kortix-ai/suna.git
wip
This commit is contained in:
parent
dabbe1415d
commit
6f64db2dad
|
@ -56,7 +56,8 @@
|
|||
"tailwindcss": "^4",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5",
|
||||
"zod": "^3.24.2"
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
|
@ -11112,6 +11113,34 @@
|
|||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.3.tgz",
|
||||
"integrity": "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/zwitch": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||
|
|
|
@ -57,7 +57,8 @@
|
|||
"tailwindcss": "^4",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5",
|
||||
"zod": "^3.24.2"
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
|
|
|
@ -687,30 +687,34 @@ export default function AgentPage({ params }: AgentPageProps) {
|
|||
// Find the running agent run
|
||||
const runningAgentId = currentAgentRunId || agentRuns.find(run => run.status === "running")?.id;
|
||||
|
||||
if (runningAgentId) {
|
||||
// Clean up stream first
|
||||
if (streamCleanupRef.current) {
|
||||
streamCleanupRef.current();
|
||||
streamCleanupRef.current = null;
|
||||
}
|
||||
if (!runningAgentId) {
|
||||
console.warn("No running agent to stop");
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up stream first
|
||||
if (streamCleanupRef.current) {
|
||||
streamCleanupRef.current();
|
||||
streamCleanupRef.current = null;
|
||||
}
|
||||
|
||||
// Stop the agent
|
||||
await stopAgent(runningAgentId);
|
||||
|
||||
// Update UI state
|
||||
setIsStreaming(false);
|
||||
setCurrentAgentRunId(null);
|
||||
|
||||
// Refresh agent runs and messages
|
||||
if (conversation) {
|
||||
const [updatedAgentRuns, updatedMessages] = await Promise.all([
|
||||
getAgentRuns(conversation.thread_id),
|
||||
getMessages(conversation.thread_id)
|
||||
]);
|
||||
|
||||
// Then stop the agent
|
||||
await stopAgent(runningAgentId);
|
||||
setIsStreaming(false);
|
||||
setCurrentAgentRunId(null);
|
||||
|
||||
// Refresh agent runs
|
||||
if (conversation) {
|
||||
const updatedAgentRuns = await getAgentRuns(conversation.thread_id);
|
||||
setAgentRuns(updatedAgentRuns);
|
||||
|
||||
// Also refresh messages to get any partial responses
|
||||
const updatedMessages = await getMessages(conversation.thread_id);
|
||||
setMessages(updatedMessages);
|
||||
|
||||
// Clear streaming content
|
||||
setStreamContent("");
|
||||
}
|
||||
setAgentRuns(updatedAgentRuns);
|
||||
setMessages(updatedMessages);
|
||||
setStreamContent("");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error stopping agent:", err);
|
||||
|
@ -752,23 +756,39 @@ export default function AgentPage({ params }: AgentPageProps) {
|
|||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-6 space-y-4">
|
||||
{typeof error === 'string' ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
error
|
||||
)}
|
||||
<div className="flex space-x-3">
|
||||
<Button variant="outline" onClick={() => router.push(`/dashboard/agents`)}>
|
||||
Back to Agents
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setError(null)}>
|
||||
Dismiss
|
||||
</Button>
|
||||
<div className="flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center p-4">
|
||||
<div className="w-full max-w-md mx-auto space-y-4 text-center">
|
||||
<div className="p-6 rounded-xl bg-background/50 border border-border/40 shadow-[0_0_15px_rgba(0,0,0,0.03)] backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="h-12 w-12 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-medium">Something went wrong</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{typeof error === 'string' ? error : 'An unexpected error occurred'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/dashboard/agents`)}
|
||||
className="rounded-lg border-border/40 shadow-[0_0_15px_rgba(0,0,0,0.03)]"
|
||||
>
|
||||
Back to Agents
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setError(null)}
|
||||
className="rounded-lg bg-primary hover:bg-primary/90 text-primary-foreground shadow-[0_0_15px_rgba(var(--primary),0.15)]"
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { SidebarLeft } from "@/components/dashboard/sidebar/sidebar-left"
|
||||
import { SidebarRight } from "@/components/dashboard/sidebar/sidebar-right"
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
|
@ -23,12 +22,12 @@ export default async function DashboardLayout({
|
|||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<SidebarLeft />
|
||||
<SidebarLeft />
|
||||
<SidebarInset>
|
||||
<header className="bg-background sticky top-0 flex h-14 shrink-0 items-center gap-2">
|
||||
<div className="flex flex-1 items-center gap-2 px-3">
|
||||
<SidebarTrigger />
|
||||
<Separator
|
||||
{/* <Separator
|
||||
orientation="vertical"
|
||||
className="mr-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
|
@ -40,10 +39,12 @@ export default async function DashboardLayout({
|
|||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</Breadcrumb> */}
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
<div className="bg-background">
|
||||
{children}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
{/* <SidebarRight /> */}
|
||||
</SidebarProvider>
|
||||
|
|
|
@ -46,8 +46,8 @@ function DashboardContent() {
|
|||
<div className="flex flex-col items-center justify-center h-full w-full">
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-[560px] max-w-[90%]">
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-4xl font-medium text-foreground mb-2">Create New Agent</h1>
|
||||
<h2 className="text-2xl text-muted-foreground">What would you like your agent to help with?</h2>
|
||||
<h1 className="text-4xl font-medium text-foreground mb-2">Hey </h1>
|
||||
<h2 className="text-2xl text-muted-foreground">What would you like Suna to do today?</h2>
|
||||
</div>
|
||||
|
||||
<ChatInput
|
||||
|
|
|
@ -4,6 +4,7 @@ import type { Metadata, Viewport } from "next";
|
|||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
|
@ -50,6 +51,7 @@ export default function RootLayout({
|
|||
>
|
||||
<Providers>
|
||||
{children}
|
||||
<Toaster />
|
||||
</Providers>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
|
|
@ -46,16 +46,21 @@ export default function LeftSidebar({
|
|||
try {
|
||||
const projectsData = await getProjects();
|
||||
const agentsList = [];
|
||||
const seenThreadIds = new Set(); // Track unique thread IDs
|
||||
|
||||
for (const project of projectsData) {
|
||||
const threads = await getThreads(project.id);
|
||||
if (threads && threads.length > 0) {
|
||||
// For each thread in the project, create an agent entry
|
||||
for (const thread of threads) {
|
||||
agentsList.push({
|
||||
name: `${project.name} - ${thread.thread_id.slice(0, 4)}`,
|
||||
href: `/dashboard/agents/${thread.thread_id}`
|
||||
});
|
||||
// Only add if we haven't seen this thread ID before
|
||||
if (!seenThreadIds.has(thread.thread_id)) {
|
||||
seenThreadIds.add(thread.thread_id);
|
||||
agentsList.push({
|
||||
name: `${project.name} - ${thread.thread_id.slice(0, 4)}`,
|
||||
href: `/dashboard/agents/${thread.thread_id}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,9 +2,18 @@
|
|||
|
||||
import Image from "next/image"
|
||||
import { useSidebar } from "@/components/ui/sidebar"
|
||||
import { useTheme } from "next-themes"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export function KortixLogo() {
|
||||
const { state } = useSidebar()
|
||||
const { theme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
// After mount, we can access the theme
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
|
@ -13,7 +22,7 @@ export function KortixLogo() {
|
|||
alt="Kortix"
|
||||
width={24}
|
||||
height={24}
|
||||
className="transition-all duration-200"
|
||||
className={`${mounted && theme === 'dark' ? 'invert' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
Plus,
|
||||
MessagesSquare,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
@ -41,16 +42,21 @@ export function NavAgents() {
|
|||
try {
|
||||
const projectsData = await getProjects()
|
||||
const agentsList = []
|
||||
const seenThreadIds = new Set() // Track unique thread IDs
|
||||
|
||||
for (const project of projectsData) {
|
||||
const threads = await getThreads(project.id)
|
||||
if (threads && threads.length > 0) {
|
||||
// For each thread in the project, create an agent entry
|
||||
for (const thread of threads) {
|
||||
agentsList.push({
|
||||
name: project.name,
|
||||
url: `/dashboard/agents/${thread.thread_id}`
|
||||
})
|
||||
// Only add if we haven't seen this thread ID before
|
||||
if (!seenThreadIds.has(thread.thread_id)) {
|
||||
seenThreadIds.add(thread.thread_id)
|
||||
agentsList.push({
|
||||
name: `${project.name} - ${thread.thread_id.slice(0, 4)}`,
|
||||
url: `/dashboard/agents/${thread.thread_id}`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -66,9 +72,6 @@ export function NavAgents() {
|
|||
loadAgents()
|
||||
}, [])
|
||||
|
||||
// Get only the latest 20 agents for the sidebar
|
||||
const recentAgents = agents.slice(0, 20)
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<div className="flex justify-between items-center">
|
||||
|
@ -83,7 +86,7 @@ export function NavAgents() {
|
|||
</Link>
|
||||
</div>
|
||||
|
||||
<SidebarMenu>
|
||||
<SidebarMenu className="overflow-y-auto max-h-[calc(100vh-200px)]">
|
||||
{isLoading ? (
|
||||
// Show skeleton loaders while loading
|
||||
Array.from({length: 3}).map((_, index) => (
|
||||
|
@ -94,10 +97,10 @@ export function NavAgents() {
|
|||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))
|
||||
) : recentAgents.length > 0 ? (
|
||||
// Show agents
|
||||
) : agents.length > 0 ? (
|
||||
// Show all agents
|
||||
<>
|
||||
{recentAgents.map((item, index) => (
|
||||
{agents.map((item, index) => (
|
||||
<SidebarMenuItem key={`agent-${index}`}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
|
@ -121,7 +124,10 @@ export function NavAgents() {
|
|||
side={isMobile ? "bottom" : "right"}
|
||||
align={isMobile ? "end" : "start"}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => {
|
||||
navigator.clipboard.writeText(window.location.origin + item.url)
|
||||
toast.success("Link copied to clipboard")
|
||||
}}>
|
||||
<LinkIcon className="text-muted-foreground" />
|
||||
<span>Copy Link</span>
|
||||
</DropdownMenuItem>
|
||||
|
@ -141,21 +147,6 @@ export function NavAgents() {
|
|||
)}
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
|
||||
{agents.length > 20 && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className="text-sidebar-foreground/70"
|
||||
tooltip={state === "collapsed" ? "See all agents" : undefined}
|
||||
>
|
||||
<Link href="/dashboard/agents">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span>See all agents</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Empty state
|
||||
|
|
|
@ -15,6 +15,8 @@ import {
|
|||
Settings,
|
||||
User,
|
||||
AudioWaveform,
|
||||
Sun,
|
||||
Moon,
|
||||
} from "lucide-react"
|
||||
import { useAccounts } from "@/hooks/use-accounts"
|
||||
import NewTeamForm from "@/components/basejump/new-team-form"
|
||||
|
@ -49,6 +51,7 @@ import {
|
|||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { createClient } from "@/lib/supabase/client"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
export function NavUserWithTeams({
|
||||
user,
|
||||
|
@ -63,6 +66,7 @@ export function NavUserWithTeams({
|
|||
const { isMobile } = useSidebar()
|
||||
const { data: accounts } = useAccounts()
|
||||
const [showNewTeamDialog, setShowNewTeamDialog] = React.useState(false)
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
// Prepare personal account and team accounts
|
||||
const personalAccount = React.useMemo(() => accounts?.find(account => account.personal_account), [accounts])
|
||||
|
@ -275,6 +279,13 @@ export function NavUserWithTeams({
|
|||
Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sun className="mr-2 h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute mr-2 h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span>Theme</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
|
|
|
@ -3,7 +3,6 @@ import { Plus } from "lucide-react"
|
|||
|
||||
import { Calendars } from "@/components/dashboard/sidebar/calendars"
|
||||
import { DatePicker } from "@/components/dashboard/sidebar/date-picker"
|
||||
import { NavUser } from "@/components/dashboard/sidebar/nav-user"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
|
@ -49,7 +48,6 @@ export function SidebarRight({
|
|||
{...props}
|
||||
>
|
||||
<SidebarHeader className="border-sidebar-border h-16 border-b">
|
||||
<NavUser user={data.user} />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<DatePicker />
|
||||
|
|
|
@ -1,14 +1,11 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { Send, Square, Loader2, File, Upload, X, Plus, Paperclip, Image } from "lucide-react";
|
||||
import { Send, Square, Loader2, File, Upload, X } from "lucide-react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { toast } from "sonner";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Define API_URL
|
||||
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
|
||||
|
@ -20,7 +17,6 @@ interface ChatInputProps {
|
|||
disabled?: boolean;
|
||||
isAgentRunning?: boolean;
|
||||
onStopAgent?: () => void;
|
||||
autoFocus?: boolean;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onFileBrowse?: () => void;
|
||||
|
@ -40,98 +36,67 @@ export function ChatInput({
|
|||
disabled = false,
|
||||
isAgentRunning = false,
|
||||
onStopAgent,
|
||||
autoFocus = true,
|
||||
value,
|
||||
value = '',
|
||||
onChange,
|
||||
onFileBrowse,
|
||||
sandboxId
|
||||
}: ChatInputProps) {
|
||||
const [inputValue, setInputValue] = useState(value || "");
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
// Allow controlled or uncontrolled usage
|
||||
const isControlled = value !== undefined && onChange !== undefined;
|
||||
|
||||
// Update local state if controlled and value changes
|
||||
useEffect(() => {
|
||||
if (isControlled && value !== inputValue) {
|
||||
setInputValue(value);
|
||||
}
|
||||
}, [value, isControlled, inputValue]);
|
||||
const [uploadedFiles, setUploadedFiles] = React.useState<UploadedFile[]>([]);
|
||||
const [isUploading, setIsUploading] = React.useState(false);
|
||||
|
||||
// Auto-focus on textarea when component loads
|
||||
useEffect(() => {
|
||||
if (autoFocus && textareaRef.current) {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, [autoFocus]);
|
||||
}, []);
|
||||
|
||||
// Adjust textarea height based on content
|
||||
// Handle textarea height adjustment
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const adjustHeight = () => {
|
||||
textarea.style.height = 'auto';
|
||||
const newHeight = Math.min(textarea.scrollHeight, 200); // Max height of 200px
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
||||
};
|
||||
|
||||
adjustHeight();
|
||||
|
||||
// Adjust on window resize too
|
||||
window.addEventListener('resize', adjustHeight);
|
||||
return () => window.removeEventListener('resize', adjustHeight);
|
||||
}, [inputValue]);
|
||||
const observer = new ResizeObserver(adjustHeight);
|
||||
observer.observe(textarea);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if ((!inputValue.trim() && uploadedFiles.length === 0) || loading || (disabled && !isAgentRunning)) return;
|
||||
|
||||
if (loading || (disabled && !isAgentRunning)) return;
|
||||
|
||||
if (isAgentRunning && onStopAgent) {
|
||||
onStopAgent();
|
||||
return;
|
||||
}
|
||||
|
||||
let message = inputValue;
|
||||
|
||||
// Add file information to the message if files were uploaded
|
||||
|
||||
const message = value.trim();
|
||||
if (!message && uploadedFiles.length === 0) return;
|
||||
|
||||
let finalMessage = message;
|
||||
if (uploadedFiles.length > 0) {
|
||||
const fileInfo = uploadedFiles.map(file =>
|
||||
`[Uploaded file: ${file.name} (${formatFileSize(file.size)}) at ${file.path}]`
|
||||
).join('\n');
|
||||
message = message ? `${message}\n\n${fileInfo}` : fileInfo;
|
||||
finalMessage = message ? `${message}\n\n${fileInfo}` : fileInfo;
|
||||
}
|
||||
|
||||
onSubmit(message);
|
||||
|
||||
if (!isControlled) {
|
||||
setInputValue("");
|
||||
}
|
||||
|
||||
// Reset the uploaded files after sending
|
||||
setUploadedFiles([]);
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value;
|
||||
if (isControlled) {
|
||||
onChange(newValue);
|
||||
} else {
|
||||
setInputValue(newValue);
|
||||
}
|
||||
onSubmit(finalMessage);
|
||||
setUploadedFiles([]);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if ((inputValue.trim() || uploadedFiles.length > 0) && !loading && (!disabled || isAgentRunning)) {
|
||||
handleSubmit(e as React.FormEvent);
|
||||
}
|
||||
handleSubmit(e as React.FormEvent);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -142,27 +107,22 @@ export function ChatInput({
|
|||
};
|
||||
|
||||
const processFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!sandboxId || !event.target.files || event.target.files.length === 0) return;
|
||||
if (!sandboxId || !event.target.files?.length) return;
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
|
||||
const files = Array.from(event.target.files);
|
||||
const newUploadedFiles: UploadedFile[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.size > 50 * 1024 * 1024) { // 50MB limit
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
toast.error(`File size exceeds 50MB limit: ${file.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create a FormData object
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
// Upload to workspace root by default
|
||||
const uploadPath = `/workspace/${file.name}`;
|
||||
formData.append('path', uploadPath);
|
||||
formData.append('path', `/workspace/${file.name}`);
|
||||
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
@ -171,12 +131,9 @@ export function ChatInput({
|
|||
throw new Error('No access token available');
|
||||
}
|
||||
|
||||
// Upload using FormData
|
||||
const response = await fetch(`${API_URL}/sandboxes/${sandboxId}/files`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.access_token}`,
|
||||
},
|
||||
headers: { 'Authorization': `Bearer ${session.access_token}` },
|
||||
body: formData
|
||||
});
|
||||
|
||||
|
@ -184,17 +141,15 @@ export function ChatInput({
|
|||
throw new Error(`Upload failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Add to uploaded files
|
||||
newUploadedFiles.push({
|
||||
name: file.name,
|
||||
path: uploadPath,
|
||||
path: `/workspace/${file.name}`,
|
||||
size: file.size
|
||||
});
|
||||
|
||||
toast.success(`File uploaded: ${file.name}`);
|
||||
}
|
||||
|
||||
// Update the uploaded files state
|
||||
setUploadedFiles(prev => [...prev, ...newUploadedFiles]);
|
||||
|
||||
} catch (error) {
|
||||
|
@ -202,7 +157,6 @@ export function ChatInput({
|
|||
toast.error(typeof error === 'string' ? error : (error instanceof Error ? error.message : "Failed to upload file"));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
// Reset the input
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
@ -220,136 +174,114 @@ export function ChatInput({
|
|||
return (
|
||||
<div className="w-full">
|
||||
<form onSubmit={handleSubmit} className="relative">
|
||||
<AnimatePresence>
|
||||
{uploadedFiles.length > 0 && (
|
||||
<motion.div
|
||||
className="space-y-1"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{uploadedFiles.map((file, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="p-2 rounded-lg bg-background border border-border/50 flex items-center justify-between shadow-sm"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div className="mb-2 space-y-1">
|
||||
{uploadedFiles.map((file, index) => (
|
||||
<div key={index} className="p-2 bg-secondary/20 rounded-md flex items-center justify-between">
|
||||
<div className="flex items-center text-sm">
|
||||
<File className="h-4 w-4 mr-2 text-primary" />
|
||||
<span className="font-medium">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
({formatFileSize(file.size)})
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => removeUploadedFile(index)}
|
||||
>
|
||||
<div className="flex items-center text-sm">
|
||||
<File className="h-4 w-4 mr-2 text-primary" />
|
||||
<span className="font-medium text-xs">{file.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground ml-2">
|
||||
({formatFileSize(file.size)})
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 rounded-full hover:bg-background"
|
||||
onClick={() => removeUploadedFile(index)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn(
|
||||
"relative bg-background border rounded-2xl transition-all duration-150",
|
||||
isFocused
|
||||
? "border-secondary/50 shadow-[0_0_15px_rgba(var(--secondary),0.2)]"
|
||||
: "border-border"
|
||||
)}>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder={
|
||||
isAgentRunning
|
||||
? "Agent is running... type 'stop' to stop"
|
||||
: placeholder
|
||||
}
|
||||
className="resize-none pr-24 min-h-[48px] max-h-[200px] rounded-2xl border-0 focus-visible:ring-0 focus-visible:ring-offset-0 shadow-none py-3"
|
||||
disabled={disabled || loading}
|
||||
aria-disabled={disabled || loading}
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={isAgentRunning ? "Agent is thinking..." : placeholder}
|
||||
className="min-h-[50px] max-h-[200px] pr-20 resize-none"
|
||||
disabled={loading || (disabled && !isAgentRunning)}
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
<div className="absolute right-2 bottom-2 flex items-center space-x-1">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleFileUpload}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
disabled={loading || (disabled && !isAgentRunning) || isUploading}
|
||||
aria-label="Upload files"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
onChange={processFileUpload}
|
||||
multiple
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-2 right-2 flex items-center space-x-1">
|
||||
{/* File upload button */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
onChange={processFileUpload}
|
||||
multiple
|
||||
disabled={disabled || loading || isUploading || !sandboxId}
|
||||
/>
|
||||
|
||||
{sandboxId && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 rounded-full bg-background/80 hover:bg-muted"
|
||||
onClick={handleFileUpload}
|
||||
disabled={disabled || loading || isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Paperclip className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Additional action button (+ button) */}
|
||||
{onFileBrowse && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 rounded-full bg-background/80 hover:bg-muted"
|
||||
onClick={onFileBrowse}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Submit or Stop button */}
|
||||
<Button
|
||||
type="submit"
|
||||
{onFileBrowse && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onFileBrowse}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-full",
|
||||
isAgentRunning
|
||||
? "bg-destructive/90 hover:bg-destructive text-white"
|
||||
: inputValue.trim() || uploadedFiles.length > 0
|
||||
? "bg-secondary hover:bg-secondary/90 text-white"
|
||||
: "bg-muted/70 text-muted-foreground"
|
||||
)}
|
||||
disabled={((!inputValue.trim() && uploadedFiles.length === 0) && !isAgentRunning) || loading || (disabled && !isAgentRunning)}
|
||||
className="h-8 w-8 rounded-full"
|
||||
disabled={loading || (disabled && !isAgentRunning)}
|
||||
aria-label="Browse files"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : isAgentRunning ? (
|
||||
<Square className="h-4 w-4" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
<File className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type={isAgentRunning ? 'button' : 'submit'}
|
||||
onClick={isAgentRunning ? onStopAgent : undefined}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
disabled={loading || (!isAgentRunning && (!value.trim() && uploadedFiles.length === 0))}
|
||||
aria-label={isAgentRunning ? 'Stop agent' : 'Send message'}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : isAgentRunning ? (
|
||||
<Square className="h-4 w-4" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{isAgentRunning && (
|
||||
<div className="mt-2 flex items-center justify-center gap-2">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<span className="inline-flex items-center">
|
||||
<Loader2 className="h-3 w-3 animate-spin mr-1" />
|
||||
Agent is thinking...
|
||||
</span>
|
||||
<span className="text-muted-foreground/60 border-l pl-1.5">
|
||||
Press <kbd className="inline-flex items-center justify-center p-0.5 bg-muted border rounded text-xs"><Square className="h-2.5 w-2.5" /></kbd> to stop
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,18 +1,20 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronDown, ChevronUp, Code, Eye, EyeOff } from 'lucide-react';
|
||||
import { Maximize2, Terminal, FileText } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ParsedTag, SUPPORTED_XML_TAGS } from "@/lib/types/tool-calls";
|
||||
import { getComponentForTag } from "@/components/thread/tool-components";
|
||||
import { motion } from "motion/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
|
||||
interface MessageDisplayProps {
|
||||
content: string;
|
||||
role: 'user' | 'assistant';
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
isStreaming?: boolean;
|
||||
showIdentifier?: boolean;
|
||||
isPartOfChain?: boolean;
|
||||
}
|
||||
|
||||
// Full implementation of XML tag parsing
|
||||
|
@ -187,183 +189,216 @@ function parseXMLTags(content: string): { parts: (string | ParsedTag)[], openTag
|
|||
interface MessageContentProps {
|
||||
content: string;
|
||||
maxHeight?: number;
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
isPartOfChain?: boolean;
|
||||
}
|
||||
|
||||
export function MessageContent({ content, maxHeight = 300 }: MessageContentProps) {
|
||||
const { parts, openTags } = parseXMLTags(content);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [showRawXml, setShowRawXml] = useState(false);
|
||||
|
||||
const hasXmlTags = parts.some(part => typeof part !== 'string');
|
||||
const isLongContent = content.length > 1000 || (parts.length > 0 && parts.some(p => typeof p !== 'string'));
|
||||
|
||||
function ToolHeader({ icon: Icon, label }: { icon: any, label: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"whitespace-pre-wrap overflow-hidden transition-all duration-200",
|
||||
!expanded && isLongContent && "max-h-[300px]"
|
||||
)}
|
||||
style={{ maxHeight: !expanded && isLongContent ? maxHeight : 'none' }}
|
||||
>
|
||||
{showRawXml ? (
|
||||
<pre className="p-2 bg-muted/30 rounded-md text-xs overflow-x-auto">
|
||||
{content}
|
||||
</pre>
|
||||
) : (
|
||||
<>
|
||||
{parts.map((part, index) => {
|
||||
if (typeof part === 'string') {
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
{part.split('\n').map((line, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{line}
|
||||
{i < part.split('\n').length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
const ToolComponent = getComponentForTag(part);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="my-2 rounded-md border border-border/30 overflow-hidden"
|
||||
>
|
||||
<div className="bg-muted/20 px-2 py-1 text-xs font-medium border-b border-border/20 flex items-center justify-between">
|
||||
<span>{part.tagName}</span>
|
||||
<Badge variant="outline" className="text-[10px] h-4 px-1.5 bg-transparent">Tool</Badge>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<ToolComponent key={index} tag={part} mode="compact" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLongContent && !expanded && (
|
||||
<div className="h-12 bg-gradient-to-t from-background to-transparent -mt-12 relative"></div>
|
||||
)}
|
||||
|
||||
{(isLongContent || hasXmlTags) && (
|
||||
<div className="flex items-center space-x-2 mt-2 text-xs text-muted-foreground">
|
||||
{isLongContent && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-2 text-xs flex items-center gap-1 rounded-md hover:bg-muted/40"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
Show less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
Show more
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{hasXmlTags && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-2 text-xs flex items-center gap-1 rounded-md hover:bg-muted/40"
|
||||
onClick={() => setShowRawXml(!showRawXml)}
|
||||
>
|
||||
{showRawXml ? (
|
||||
<>
|
||||
<EyeOff className="h-3 w-3" />
|
||||
Hide XML
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Code className="h-3 w-3" />
|
||||
Show XML
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageDisplay({ content, role, isStreaming = false }: MessageDisplayProps) {
|
||||
function CodeBlock({ content, filename }: { content: string, filename?: string }) {
|
||||
return (
|
||||
<div className="w-full max-w-screen-lg mb-4 px-4">
|
||||
{role === "user" && (
|
||||
<div className="text-xs text-muted-foreground mb-1.5 font-medium">You</div>
|
||||
<div className="font-mono text-sm">
|
||||
{filename && (
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/30 bg-background/50 rounded-t-xl">
|
||||
<span className="text-xs text-muted-foreground font-medium">{filename}</span>
|
||||
<div className="flex gap-1.5">
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-muted/30 font-medium">Diff</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-muted/30 font-medium">Original</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium">Modified</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{role === "assistant" && (
|
||||
<div className="text-xs text-muted-foreground mb-1.5 font-medium">Suna</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[90%]",
|
||||
role === "user" ? "bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800" :
|
||||
"bg-white dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800",
|
||||
"rounded-lg p-3 shadow-sm"
|
||||
)}
|
||||
>
|
||||
<MessageContent content={content} />
|
||||
{isStreaming && (
|
||||
<span
|
||||
className="inline-block h-4 w-0.5 bg-foreground/50 mx-px"
|
||||
style={{
|
||||
opacity: 0.7,
|
||||
animation: 'cursorBlink 1s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<style jsx global>{`
|
||||
@keyframes cursorBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
<div className="p-4 bg-muted/[0.02] rounded-b-xl">
|
||||
<pre className="whitespace-pre-wrap text-[13px] leading-relaxed">{content}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageContent({ content, maxHeight = 300, role, isPartOfChain }: MessageContentProps) {
|
||||
const { parts } = parseXMLTags(content);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const isTool = role === 'tool';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn(
|
||||
"w-full",
|
||||
isPartOfChain && "pl-6 border-l border-border/20"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"w-full overflow-hidden rounded-xl border border-border/40 bg-background/50 backdrop-blur-sm shadow-[0_0_15px_rgba(0,0,0,0.03)]",
|
||||
isTool && "font-mono text-[13px]"
|
||||
)}>
|
||||
{parts.map((part, index) => {
|
||||
if (typeof part === 'string') {
|
||||
return (
|
||||
<div key={index} className="p-4">
|
||||
{part.split('\n').map((line, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<span className="leading-relaxed">{line}</span>
|
||||
{i < part.split('\n').length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const isCreateFile = part.tagName === 'create-file';
|
||||
const isExecuteCommand = part.tagName === 'execute-command';
|
||||
|
||||
return (
|
||||
<div key={index}>
|
||||
<div className="px-4 py-2 border-b border-border/30">
|
||||
{isCreateFile && (
|
||||
<ToolHeader
|
||||
icon={FileText}
|
||||
label={`Creating file ${part.attributes.file_path || ''}`}
|
||||
/>
|
||||
)}
|
||||
{isExecuteCommand && (
|
||||
<ToolHeader
|
||||
icon={Terminal}
|
||||
label="Executing command"
|
||||
/>
|
||||
)}
|
||||
{!isCreateFile && !isExecuteCommand && (
|
||||
<ToolHeader
|
||||
icon={Terminal}
|
||||
label={part.tagName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CodeBlock
|
||||
content={part.content}
|
||||
filename={isCreateFile ? part.attributes.file_path : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
<SheetContent side="right" className="w-[600px] sm:w-[800px] bg-background">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-left">
|
||||
{isTool ? 'Command Output' : 'Message Content'}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-4 overflow-y-auto max-h-[calc(100vh-8rem)] px-2">
|
||||
<CodeBlock content={content} />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageDisplay({ content, role, isStreaming = false, showIdentifier = false, isPartOfChain = false }: MessageDisplayProps) {
|
||||
const isUser = role === 'user';
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"w-full px-4 py-3 flex flex-col",
|
||||
isUser ? "bg-transparent" : "bg-muted/[0.03]",
|
||||
isStreaming && "animate-pulse",
|
||||
isPartOfChain && "pt-0"
|
||||
)}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
>
|
||||
<div className="w-full max-w-5xl mx-auto relative">
|
||||
{!isUser && showIdentifier && (
|
||||
<motion.div
|
||||
className="flex items-center gap-2 mb-2"
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: 0.1 }}
|
||||
>
|
||||
<div className="flex items-center bg-background rounded-full size-8 flex-shrink-0 justify-center shadow-[0_0_10px_rgba(0,0,0,0.05)] border border-border">
|
||||
<Image
|
||||
src="/kortix-symbol.svg"
|
||||
alt="Suna"
|
||||
width={16}
|
||||
height={16}
|
||||
className={theme === 'dark' ? 'invert' : ''}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium">Suna</span>
|
||||
</motion.div>
|
||||
)}
|
||||
<MessageContent
|
||||
content={content}
|
||||
role={role}
|
||||
isPartOfChain={isPartOfChain}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThinkingIndicator() {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-screen-lg mb-4 px-4">
|
||||
<div className="text-xs text-muted-foreground mb-1.5 font-medium">Suna</div>
|
||||
|
||||
<div className="max-w-[90%] bg-white dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 rounded-lg p-3 shadow-sm">
|
||||
<div className="flex items-center space-x-1.5">
|
||||
<motion.div
|
||||
animate={{ scale: [0.8, 1, 0.8] }}
|
||||
transition={{ repeat: Infinity, duration: 1.5, ease: "easeInOut" }}
|
||||
className="w-1.5 h-1.5 bg-muted-foreground/40 rounded-full"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{ scale: [0.8, 1, 0.8] }}
|
||||
transition={{ repeat: Infinity, duration: 1.5, ease: "easeInOut", delay: 0.2 }}
|
||||
className="w-1.5 h-1.5 bg-muted-foreground/40 rounded-full"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{ scale: [0.8, 1, 0.8] }}
|
||||
transition={{ repeat: Infinity, duration: 1.5, ease: "easeInOut", delay: 0.4 }}
|
||||
className="w-1.5 h-1.5 bg-muted-foreground/40 rounded-full"
|
||||
/>
|
||||
<motion.div
|
||||
className="w-full px-4 py-3 flex flex-col bg-muted/[0.03]"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
>
|
||||
<div className="w-full max-w-5xl mx-auto">
|
||||
<motion.div
|
||||
className="flex items-center gap-2 mb-2"
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: 0.1 }}
|
||||
>
|
||||
<div className="flex items-center bg-background rounded-full size-8 flex-shrink-0 justify-center shadow-[0_0_10px_rgba(0,0,0,0.05)] border border-border">
|
||||
<Image
|
||||
src="/kortix-symbol.svg"
|
||||
alt="Suna"
|
||||
width={16}
|
||||
height={16}
|
||||
className={theme === 'dark' ? 'invert' : ''}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium">Suna</span>
|
||||
</motion.div>
|
||||
|
||||
<div className="w-full overflow-hidden rounded-xl border border-border/40 bg-background/50 backdrop-blur-sm shadow-[0_0_15px_rgba(0,0,0,0.03)]">
|
||||
<div className="p-4">
|
||||
<div className="flex gap-1.5">
|
||||
{[0, 1, 2].map((index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="w-2 h-2 bg-primary/40 rounded-full"
|
||||
animate={{ y: [0, -5, 0] }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
repeat: Infinity,
|
||||
delay: index * 0.2,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -5,6 +5,8 @@ import { Message } from "@/lib/api";
|
|||
import { MessageDisplay, ThinkingIndicator, EmptyChat } from "@/components/thread/message-display";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
|
||||
type MessageRole = 'user' | 'assistant' | 'tool';
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
streamContent?: string;
|
||||
|
@ -18,7 +20,7 @@ export function MessageList({
|
|||
streamContent = "",
|
||||
isStreaming = false,
|
||||
isAgentRunning = false,
|
||||
agentName = "AI assistant"
|
||||
agentName = "Suna"
|
||||
}: MessageListProps) {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
@ -27,12 +29,12 @@ export function MessageList({
|
|||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, streamContent]);
|
||||
|
||||
// Filter out invalid messages and tool results
|
||||
// Filter out invalid messages
|
||||
const filteredMessages = messages.filter(message =>
|
||||
message &&
|
||||
message.content &&
|
||||
message.role &&
|
||||
!message.content.includes("ToolResult(")
|
||||
message.role &&
|
||||
(message.role === 'user' || message.role === 'assistant' || message.role === 'tool')
|
||||
);
|
||||
|
||||
// If no messages and not streaming, show empty state
|
||||
|
@ -41,26 +43,43 @@ export function MessageList({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-y-auto overflow-x-hidden py-6 pb-28">
|
||||
<div className="w-full max-w-3xl mx-auto">
|
||||
<div className="flex flex-col flex-1 overflow-y-auto overflow-x-hidden bg-background">
|
||||
<div className="w-full">
|
||||
<AnimatePresence initial={false}>
|
||||
{filteredMessages.map((message, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
delay: 0.05
|
||||
}}
|
||||
>
|
||||
<MessageDisplay
|
||||
content={message.content}
|
||||
role={message.role as 'user' | 'assistant'}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
{filteredMessages.map((message, index) => {
|
||||
const prevMessage = index > 0 ? filteredMessages[index - 1] : null;
|
||||
const nextMessage = index < filteredMessages.length - 1 ? filteredMessages[index + 1] : null;
|
||||
|
||||
// Show identifier if this is an AI/tool message that follows a user message
|
||||
const showIdentifier = message.role !== 'user' &&
|
||||
(!prevMessage || prevMessage.role === 'user');
|
||||
|
||||
// Part of a chain if:
|
||||
// 1. Current message is tool/assistant AND
|
||||
// 2. Previous message was also tool/assistant
|
||||
const isPartOfChain = message.role !== 'user' &&
|
||||
prevMessage?.role !== 'user';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
delay: 0.05
|
||||
}}
|
||||
>
|
||||
<MessageDisplay
|
||||
content={message.content}
|
||||
role={message.role as MessageRole}
|
||||
showIdentifier={showIdentifier}
|
||||
isPartOfChain={isPartOfChain}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
|
||||
{streamContent && (
|
||||
|
@ -73,6 +92,8 @@ export function MessageList({
|
|||
content={streamContent}
|
||||
role="assistant"
|
||||
isStreaming={isStreaming}
|
||||
showIdentifier={filteredMessages.length === 0 || filteredMessages[filteredMessages.length - 1].role === 'user'}
|
||||
isPartOfChain={filteredMessages.length > 0 && filteredMessages[filteredMessages.length - 1].role !== 'user'}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
@ -81,7 +102,7 @@ export function MessageList({
|
|||
<ThinkingIndicator />
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
<div ref={messagesEndRef} className="h-24" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -345,31 +345,23 @@ export const addUserMessage = async (threadId: string, content: string): Promise
|
|||
apiCache.invalidateThreadMessages(threadId);
|
||||
};
|
||||
|
||||
export const getMessages = async (threadId: string, hideToolMsgs: boolean = false): Promise<Message[]> => {
|
||||
export const getMessages = async (threadId: string): Promise<Message[]> => {
|
||||
// Check if we already have a pending request
|
||||
const pendingRequest = fetchQueue.getQueuedMessages(threadId);
|
||||
if (pendingRequest) {
|
||||
// Apply filter if needed when promise resolves
|
||||
if (hideToolMsgs) {
|
||||
return pendingRequest.then(messages =>
|
||||
messages.filter((msg: Message) => msg.role !== 'tool')
|
||||
);
|
||||
}
|
||||
return pendingRequest;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
const cached = apiCache.getThreadMessages(threadId);
|
||||
if (cached) {
|
||||
// Apply filter if needed
|
||||
return hideToolMsgs ? cached.filter((msg: Message) => msg.role !== 'tool') : cached;
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Create and queue the promise
|
||||
const fetchPromise = (async () => {
|
||||
const supabase = createClient();
|
||||
|
||||
// Query all messages for the thread instead of using RPC
|
||||
const { data, error } = await supabase
|
||||
.from('messages')
|
||||
.select('*')
|
||||
|
@ -408,17 +400,8 @@ export const getMessages = async (threadId: string, hideToolMsgs: boolean = fals
|
|||
return messages;
|
||||
})();
|
||||
|
||||
// Add to queue
|
||||
const queuedPromise = fetchQueue.setQueuedMessages(threadId, fetchPromise);
|
||||
|
||||
// Apply filter if needed when promise resolves
|
||||
if (hideToolMsgs) {
|
||||
return queuedPromise.then(messages =>
|
||||
messages.filter((msg: Message) => msg.role !== 'tool')
|
||||
);
|
||||
}
|
||||
|
||||
return queuedPromise;
|
||||
// Add to queue and return
|
||||
return fetchQueue.setQueuedMessages(threadId, fetchPromise);
|
||||
};
|
||||
|
||||
// Agent APIs
|
||||
|
|
Loading…
Reference in New Issue