This commit is contained in:
marko-kraemer 2025-04-16 01:04:04 +01:00
parent dabbe1415d
commit 6f64db2dad
15 changed files with 528 additions and 490 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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>
);

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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}`
});
}
}
}
}

View File

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

View File

@ -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

View File

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

View File

@ -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 />

View File

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

View File

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

View File

@ -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>
);

View File

@ -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