Files
EvoScientist-WebUI/src/app/components/TasksFilesSidebar.tsx
T
Xi Zhang 38f8f0d9d8 Initial
2026-05-31 22:45:33 +01:00

267 lines
8.7 KiB
TypeScript

"use client";
import React, {
useMemo,
useCallback,
useState,
useEffect,
useRef,
} from "react";
import {
FileText,
CheckCircle,
Circle,
Clock,
ChevronDown,
} from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { TodoItem, FileItem } from "@/app/types/types";
import { useChatContext } from "@/providers/ChatProvider";
import { cn } from "@/lib/utils";
import { FileViewDialog } from "@/app/components/FileViewDialog";
export function FilesPopover({
files,
setFiles,
editDisabled,
}: {
files: Record<string, string>;
setFiles: (files: Record<string, string>) => Promise<void>;
editDisabled: boolean;
}) {
const [selectedFile, setSelectedFile] = useState<FileItem | null>(null);
const handleSaveFile = useCallback(
async (fileName: string, content: string) => {
await setFiles({ ...files, [fileName]: content });
setSelectedFile({ path: fileName, content: content });
},
[files, setFiles]
);
return (
<>
{Object.keys(files).length === 0 ? (
<div className="flex h-full items-center justify-center p-4 text-center">
<p className="text-xs text-muted-foreground">No files created yet</p>
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(256px,1fr))] gap-2">
{Object.keys(files).map((file) => {
const filePath = String(file);
const rawContent = files[file];
let fileContent: string;
if (
typeof rawContent === "object" &&
rawContent !== null &&
"content" in rawContent
) {
const contentArray = (rawContent as { content: unknown }).content;
if (Array.isArray(contentArray)) {
fileContent = contentArray.join("\n");
} else {
fileContent = String(contentArray || "");
}
} else {
fileContent = String(rawContent || "");
}
return (
<button
key={filePath}
type="button"
onClick={() =>
setSelectedFile({ path: filePath, content: fileContent })
}
className="cursor-pointer space-y-1 truncate rounded-md border border-border px-2 py-3 shadow-sm transition-colors"
style={{
backgroundColor: "var(--color-file-button)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
"var(--color-file-button-hover)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor =
"var(--color-file-button)";
}}
>
<FileText
size={24}
className="mx-auto text-muted-foreground"
/>
<span className="mx-auto block w-full truncate break-words text-center text-sm leading-relaxed text-foreground">
{filePath}
</span>
</button>
);
})}
</div>
)}
{selectedFile && (
<FileViewDialog
file={selectedFile}
onSaveFile={handleSaveFile}
onClose={() => setSelectedFile(null)}
editDisabled={editDisabled}
/>
)}
</>
);
}
export const TasksFilesSidebar = React.memo<{
todos: TodoItem[];
files: Record<string, string>;
setFiles: (files: Record<string, string>) => Promise<void>;
}>(({ todos, files, setFiles }) => {
const { isLoading, interrupt } = useChatContext();
const [tasksOpen, setTasksOpen] = useState(false);
const [filesOpen, setFilesOpen] = useState(false);
// Track previous counts to detect when content goes from empty to having items
const prevTodosCount = useRef(todos.length);
const prevFilesCount = useRef(Object.keys(files).length);
// Auto-expand when todos go from empty to having content
useEffect(() => {
if (prevTodosCount.current === 0 && todos.length > 0) {
setTasksOpen(true);
}
prevTodosCount.current = todos.length;
}, [todos.length]);
// Auto-expand when files go from empty to having content
const filesCount = Object.keys(files).length;
useEffect(() => {
if (prevFilesCount.current === 0 && filesCount > 0) {
setFilesOpen(true);
}
prevFilesCount.current = filesCount;
}, [filesCount]);
const getStatusIcon = useCallback((status: TodoItem["status"]) => {
switch (status) {
case "completed":
return (
<CheckCircle
size={12}
className="text-success/80"
/>
);
case "in_progress":
return (
<Clock
size={12}
className="text-warning/80"
/>
);
default:
return (
<Circle
size={10}
className="text-tertiary/70"
/>
);
}
}, []);
const groupedTodos = useMemo(() => {
return {
pending: todos.filter((t) => t.status === "pending"),
in_progress: todos.filter((t) => t.status === "in_progress"),
completed: todos.filter((t) => t.status === "completed"),
};
}, [todos]);
const groupedLabels = {
pending: "Pending",
in_progress: "In Progress",
completed: "Completed",
};
return (
<div className="min-h-0 w-full flex-1">
<div className="font-inter flex h-full w-full flex-col p-0">
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
<div className="flex items-center justify-between px-3 pb-1.5 pt-2">
<span className="text-xs font-semibold tracking-wide text-zinc-600">
AGENT TASKS
</span>
<button
onClick={() => setTasksOpen((v) => !v)}
className={cn(
"flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-transform duration-200 hover:bg-muted",
tasksOpen ? "rotate-180" : "rotate-0"
)}
aria-label="Toggle tasks panel"
>
<ChevronDown size={14} />
</button>
</div>
{tasksOpen && (
<div className="bg-muted-secondary rounded-xl px-3 pb-2">
<ScrollArea className="h-full">
{todos.length === 0 ? (
<div className="flex h-full items-center justify-center p-4 text-center">
<p className="text-xs text-muted-foreground">
No tasks created yet
</p>
</div>
) : (
<div className="ml-1 p-0.5">
{Object.entries(groupedTodos).map(([status, todos]) => (
<div className="mb-4">
<h3 className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-tertiary">
{groupedLabels[status as keyof typeof groupedLabels]}
</h3>
{todos.map((todo, index) => (
<div
key={`${status}_${todo.id}_${index}`}
className="mb-1.5 flex items-start gap-2 rounded-sm p-1 text-sm"
>
{getStatusIcon(todo.status)}
<span className="flex-1 break-words leading-relaxed text-inherit">
{todo.content}
</span>
</div>
))}
</div>
))}
</div>
)}
</ScrollArea>
</div>
)}
<div className="flex items-center justify-between px-3 pb-1.5 pt-2">
<span className="text-xs font-semibold tracking-wide text-zinc-600">
FILE SYSTEM
</span>
<button
onClick={() => setFilesOpen((v) => !v)}
className={cn(
"flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-transform duration-200 hover:bg-muted",
filesOpen ? "rotate-180" : "rotate-0"
)}
aria-label="Toggle files panel"
>
<ChevronDown size={14} />
</button>
</div>
{filesOpen && (
<FilesPopover
files={files}
setFiles={setFiles}
editDisabled={isLoading === true || interrupt !== undefined}
/>
)}
</div>
</div>
</div>
);
});
TasksFilesSidebar.displayName = "TasksFilesSidebar";