feat: init grouped history views

This commit is contained in:
a7m-1st 2025-10-24 16:25:47 +03:00
parent b2b1435108
commit e70c8af4fa
9 changed files with 656 additions and 27 deletions

View file

@ -0,0 +1,148 @@
import React, { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronDown, ChevronRight, Folder, Calendar, Target, Clock } from "lucide-react";
import { ProjectGroup as ProjectGroupType, HistoryTask } from "@/types/history";
import { Button } from "@/components/ui/button";
import { Tag } from "@/components/ui/tag";
import { TooltipSimple } from "@/components/ui/tooltip";
import { useTranslation } from "react-i18next";
import TaskItem from "./TaskItem";
interface ProjectGroupProps {
project: ProjectGroupType;
onTaskSelect: (projectId: string, question: string, historyId: string) => void;
onTaskDelete: (taskId: string) => void;
onTaskShare: (taskId: string) => void;
activeTaskId?: string;
searchValue?: string;
}
export default function ProjectGroup({
project,
onTaskSelect,
onTaskDelete,
onTaskShare,
activeTaskId,
searchValue = ""
}: ProjectGroupProps) {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(true);
// Filter tasks based on search value
const filteredTasks = project.tasks.filter(task =>
task.question?.toLowerCase().includes(searchValue.toLowerCase())
);
// Don't render if no tasks match the search
if (searchValue && filteredTasks.length === 0) {
return null;
}
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffInDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
if (diffInDays === 0) return t("layout.today");
if (diffInDays === 1) return t("layout.yesterday");
if (diffInDays < 7) return `${diffInDays} ${t("layout.days-ago")}`;
return date.toLocaleDateString();
};
const getStatusColor = (completedTasks: number, totalTasks: number) => {
const ratio = totalTasks > 0 ? completedTasks / totalTasks : 0;
if (ratio >= 0.8) return "text-green-600";
if (ratio >= 0.5) return "text-yellow-600";
return "text-red-600";
};
return (
<div className="border border-solid border-border-disabled rounded-xl bg-white-30% overflow-hidden">
{/* Project Header */}
<Button
variant="ghost"
className="w-full p-4 h-auto justify-start hover:bg-white-50% transition-all duration-200"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-icon-secondary" />
) : (
<ChevronRight className="w-4 h-4 text-icon-secondary" />
)}
<Folder className="w-5 h-5 text-icon-primary" />
</div>
<div className="flex flex-col items-start gap-1">
<TooltipSimple
content={<p className="max-w-xs break-words">{project.project_name}</p>}
className="bg-surface-tertiary p-2 text-wrap break-words text-label-xs select-text pointer-events-auto shadow-perfect"
>
<span className="text-text-primary font-semibold text-left truncate max-w-[200px]">
{project.project_name}
</span>
</TooltipSimple>
<div className="flex items-center gap-2 text-xs text-text-secondary">
<div className="flex items-center gap-1">
<Target className="w-3 h-3" />
<span>{project.task_count} {t("layout.tasks")}</span>
</div>
<div className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
<span>{formatDate(project.latest_task_date)}</span>
</div>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Tag
variant="primary"
className="text-xs"
>
{t("layout.token")} {project.total_tokens.toLocaleString()}
</Tag>
<Tag
variant="primary"
className={`text-xs ${getStatusColor(project.total_completed_tasks, project.task_count)}`}
>
{project.total_completed_tasks}/{project.task_count} {t("layout.completed")}
</Tag>
</div>
</div>
</Button>
{/* Tasks List */}
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="px-4 pb-4 space-y-2">
{(searchValue ? filteredTasks : project.tasks).map((task, index) => (
<TaskItem
key={task.id}
task={task}
isActive={activeTaskId === task.task_id}
onSelect={() => onTaskSelect(task.project_id, task.question, task.id.toString())}
onDelete={() => onTaskDelete(task.id.toString())}
onShare={() => onTaskShare(task.task_id)}
isLast={index === (searchValue ? filteredTasks : project.tasks).length - 1}
/>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View file

@ -0,0 +1,176 @@
import React from "react";
import { Ellipsis, Share, Trash2, Clock, CheckCircle, XCircle } from "lucide-react";
import { HistoryTask } from "@/types/history";
import { Button } from "@/components/ui/button";
import { Tag } from "@/components/ui/tag";
import { TooltipSimple } from "@/components/ui/tooltip";
import {
Popover,
PopoverContent,
PopoverTrigger,
PopoverClose,
} from "@/components/ui/popover";
import { useTranslation } from "react-i18next";
import folderIcon from "@/assets/Folder-1.svg";
interface TaskItemProps {
task: HistoryTask;
isActive: boolean;
onSelect: () => void;
onDelete: () => void;
onShare: () => void;
isLast: boolean;
}
export default function TaskItem({
task,
isActive,
onSelect,
onDelete,
onShare,
isLast
}: TaskItemProps) {
const { t } = useTranslation();
const getStatusIcon = (status: number) => {
switch (status) {
case 1:
return <Clock className="w-3 h-3 text-yellow-500" />;
case 2:
return <CheckCircle className="w-3 h-3 text-green-500" />;
case 3:
return <XCircle className="w-3 h-3 text-red-500" />;
default:
return <Clock className="w-3 h-3 text-gray-500" />;
}
};
const getStatusText = (status: number) => {
switch (status) {
case 1:
return t("layout.running");
case 2:
return t("layout.completed");
case 3:
return t("layout.failed");
default:
return t("layout.unknown");
}
};
const formatDate = (dateString?: string) => {
if (!dateString) return "";
const date = new Date(dateString);
return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
return (
<div
onClick={onSelect}
className={`
${isActive ? "!bg-white-100%" : ""}
relative cursor-pointer transition-all duration-300 bg-white-30% hover:bg-white-100%
rounded-xl flex justify-between items-center gap-md w-full p-3 h-14
shadow-history-item border border-solid border-border-disabled
${!isLast ? "mb-2" : ""}
`}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<img className="w-6 h-6 flex-shrink-0" src={folderIcon} alt="task-icon" />
<div className="flex flex-col gap-1 flex-1 min-w-0">
<TooltipSimple
align="start"
className="max-w-xs bg-surface-tertiary p-2 text-wrap break-words text-label-xs select-text pointer-events-auto shadow-perfect"
content={
<div className="space-y-1">
<div className="font-medium">{task.question}</div>
{task.summary && (
<div className="text-xs opacity-75">{task.summary}</div>
)}
<div className="text-xs opacity-60">
{t("layout.created")}: {formatDate(task.created_at)}
</div>
</div>
}
>
<span className="text-text-body font-medium text-sm overflow-hidden text-ellipsis whitespace-nowrap block">
{task.question || t("layout.new-project")}
</span>
</TooltipSimple>
{task.summary && (
<span className="text-xs text-text-secondary overflow-hidden text-ellipsis whitespace-nowrap">
{task.summary}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-1">
{getStatusIcon(task.status)}
<span className="text-xs text-text-secondary hidden sm:inline">
{getStatusText(task.status)}
</span>
</div>
<Tag
variant="primary"
className="text-xs leading-17 font-medium text-nowrap"
>
{task.tokens ? task.tokens.toLocaleString() : "0"}
</Tag>
<Popover>
<PopoverTrigger asChild>
<Button
size="icon"
onClick={(e) => e.stopPropagation()}
variant="ghost"
className="h-8 w-8 flex-shrink-0"
>
<Ellipsis size={14} className="text-text-primary" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[98px] p-sm rounded-[12px] bg-dropdown-bg border border-solid border-dropdown-border">
<div className="space-y-1">
<PopoverClose asChild>
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={(e) => {
e.stopPropagation();
onShare();
}}
>
<Share size={14} />
{t("layout.share")}
</Button>
</PopoverClose>
<PopoverClose asChild>
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2
size={14}
className="text-icon-primary group-hover:text-icon-cuation"
/>
{t("layout.delete")}
</Button>
</PopoverClose>
</div>
</PopoverContent>
</Popover>
</div>
</div>
);
}

View file

@ -0,0 +1,122 @@
import React, { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ProjectGroup as ProjectGroupType } from "@/types/history";
import { fetchGroupedHistoryTasks } from "@/service/historyApi";
import ProjectGroup from "./ProjectGroup";
import { useTranslation } from "react-i18next";
import { Loader2, FolderOpen } from "lucide-react";
interface GroupedHistoryViewProps {
searchValue?: string;
onTaskSelect: (projectId: string, question: string, historyId: string) => void;
onTaskDelete: (taskId: string) => void;
onTaskShare: (taskId: string) => void;
activeTaskId?: string;
refreshTrigger?: number; // For triggering refresh from parent
}
export default function GroupedHistoryView({
searchValue = "",
onTaskSelect,
onTaskDelete,
onTaskShare,
activeTaskId,
refreshTrigger
}: GroupedHistoryViewProps) {
const { t } = useTranslation();
const [projects, setProjects] = useState<ProjectGroupType[]>([]);
const [loading, setLoading] = useState(true);
const loadProjects = async () => {
setLoading(true);
try {
await fetchGroupedHistoryTasks(setProjects);
} catch (error) {
console.error("Failed to load grouped projects:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadProjects();
}, [refreshTrigger]);
// Filter projects based on search value
const filteredProjects = projects.filter(project => {
if (!searchValue) return true;
// Check if project name matches
if (project.project_name?.toLowerCase().includes(searchValue.toLowerCase())) {
return true;
}
// Check if any task in the project matches
return project.tasks.some(task =>
task.question?.toLowerCase().includes(searchValue.toLowerCase())
);
});
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="w-6 h-6 animate-spin text-icon-secondary" />
<span className="ml-2 text-text-secondary">{t("layout.loading")}</span>
</div>
);
}
if (filteredProjects.length === 0) {
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<FolderOpen className="w-12 h-12 text-icon-tertiary mb-4" />
<div className="text-text-secondary text-sm">
{searchValue
? t("dashboard.no-projects-match-search")
: t("dashboard.no-projects-found")
}
</div>
{searchValue && (
<div className="text-text-tertiary text-xs mt-1">
{t("dashboard.try-different-search")}
</div>
)}
</div>
);
}
return (
<div className="space-y-4">
<AnimatePresence mode="popLayout">
{filteredProjects.map((project) => (
<motion.div
key={project.project_id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
layout
>
<ProjectGroup
project={project}
onTaskSelect={onTaskSelect}
onTaskDelete={onTaskDelete}
onTaskShare={onTaskShare}
activeTaskId={activeTaskId}
searchValue={searchValue}
/>
</motion.div>
))}
</AnimatePresence>
{/* Summary footer */}
<div className="flex justify-center pt-4 border-t border-border-disabled">
<div className="text-xs text-text-tertiary">
{filteredProjects.length} {t("layout.projects")} {" "}
{filteredProjects.reduce((total, project) => total + project.task_count, 0)} {t("layout.total-tasks")} {" "}
{filteredProjects.reduce((total, project) => total + project.total_tokens, 0).toLocaleString()} {t("layout.total-tokens")}
</div>
</div>
</div>
);
}

View file

@ -40,6 +40,7 @@ import { useTranslation } from "react-i18next";
import useChatStoreAdapter from "@/hooks/useChatStoreAdapter";
import {getAuthStore} from "@/store/authStore";
import { fetchHistoryTasks } from "@/service/historyApi";
import GroupedHistoryView from "@/components/GroupedHistoryView";
export default function HistorySidebar() {
const { t } = useTranslation();
@ -497,6 +498,16 @@ export default function HistorySidebar() {
);
})}
</div>
) : history_type === "grouped" ? (
// Grouped view
<GroupedHistoryView
searchValue={searchValue}
onTaskSelect={handleSetActive}
onTaskDelete={handleDelete}
onTaskShare={handleShare}
activeTaskId={chatStore.activeTaskId}
refreshTrigger={chatStore.updateCount}
/>
) : (
// List
*/}

View file

@ -32,11 +32,14 @@ import { useTranslation } from "react-i18next";
import useChatStoreAdapter from "@/hooks/useChatStoreAdapter";
import { replayProject } from "@/lib";
import { fetchHistoryTasks } from "@/service/historyApi";
import GroupedHistoryView from "@/components/GroupedHistoryView";
import { useGlobalStore } from "@/store/globalStore";
export function SearchHistoryDialog() {
const {t} = useTranslation()
const [open, setOpen] = useState(false);
const [historyTasks, setHistoryTasks] = useState<any[]>([]);
const { history_type } = useGlobalStore();
//Get Chatstore for the active project's task
const { chatStore, projectStore } = useChatStoreAdapter();
if (!chatStore) {
@ -52,7 +55,7 @@ export function SearchHistoryDialog() {
projectStore.setHistoryId(projectId, historyId);
projectStore.setActiveProject(projectId)
navigate(`/`);
close();
setOpen(false);
} else {
// if there is no record, execute replay
handleReplay(projectId, question, historyId);
@ -60,10 +63,20 @@ export function SearchHistoryDialog() {
};
const handleReplay = async (projectId: string, question: string, historyId: string) => {
close();
setOpen(false);
await replayProject(projectStore, navigate, projectId, question, historyId);
};
const handleDelete = (taskId: string) => {
// TODO: Implement delete functionality similar to HistorySidebar
console.log("Delete task:", taskId);
};
const handleShare = (taskId: string) => {
// TODO: Implement share functionality similar to HistorySidebar
console.log("Share task:", taskId);
};
useEffect(() => {
fetchHistoryTasks(setHistoryTasks);
}, []);
@ -85,24 +98,35 @@ export function SearchHistoryDialog() {
<CommandInput placeholder={t("dashboard.search-dialog-placeholder")} />
<CommandList>
<CommandEmpty>{t("dashboard.no-results")}</CommandEmpty>
<CommandGroup heading="Today">
{historyTasks.map((task) => (
<CommandItem
key={task.id}
className="cursor-pointer"
/**
* TODO(history): Update to use project_id field
* after update instead.
*/
onSelect={() => handleSetActive(task.task_id, task.question, task.id)}
>
<ScanFace />
<div className="overflow-hidden text-ellipsis whitespace-nowrap">
{task.question}
</div>
</CommandItem>
))}
</CommandGroup>
{history_type === "grouped" ? (
<div className="p-4">
<GroupedHistoryView
onTaskSelect={handleSetActive}
onTaskDelete={handleDelete}
onTaskShare={handleShare}
activeTaskId={chatStore.activeTaskId || undefined}
/>
</div>
) : (
<CommandGroup heading="Today">
{historyTasks.map((task) => (
<CommandItem
key={task.id}
className="cursor-pointer"
/**
* TODO(history): Update to use project_id field
* after update instead.
*/
onSelect={() => handleSetActive(task.task_id, task.question, task.id)}
>
<ScanFace />
<div className="overflow-hidden text-ellipsis whitespace-nowrap">
{task.question}
</div>
</CommandItem>
))}
</CommandGroup>
)}
<CommandSeparator />
</CommandList>
</CommandDialog>

View file

@ -42,6 +42,7 @@ import { share } from "@/lib/share";
import { useTranslation } from "react-i18next";
import AlertDialog from "@/components/ui/alertDialog";
import { fetchHistoryTasks } from "@/service/historyApi";
import GroupedHistoryView from "@/components/GroupedHistoryView";
export default function Project() {
@ -548,6 +549,16 @@ export default function Project() {
);
})}
</div>
) : history_type === "grouped" ? (
// Grouped view
<div className="p-6 pb-40">
<GroupedHistoryView
onTaskSelect={handleSetActive}
onTaskDelete={handleDelete}
onTaskShare={handleShare}
activeTaskId={chatStore.activeTaskId || undefined}
/>
</div>
) : (
// List
<div className="p-6 flex flex-col justify-start items-center gap-4 pb-40">

View file

@ -1,4 +1,66 @@
import { proxyFetchGet } from "@/api/http";
import { HistoryTask, ProjectGroup, GroupedHistoryResponse } from "@/types/history";
// Group tasks by project_id and add project-level metadata
const groupTasksByProject = (tasks: HistoryTask[]): ProjectGroup[] => {
const projectMap = new Map<string, ProjectGroup>();
tasks.forEach(task => {
const projectId = task.project_id;
if (!projectMap.has(projectId)) {
projectMap.set(projectId, {
project_id: projectId,
project_name: task.project_name || `Project ${projectId}`,
total_tokens: 0,
task_count: 0,
latest_task_date: task.created_at || new Date().toISOString(),
tasks: [],
total_completed_tasks: 0,
total_failed_tasks: 0,
average_tokens_per_task: 0
});
}
const project = projectMap.get(projectId)!;
project.tasks.push(task);
project.task_count++;
project.total_tokens += task.tokens || 0;
// Count status-based metrics
if (task.status === 2) { // Assuming 2 is completed
project.total_completed_tasks++;
} else if (task.status === 3) { // Assuming 3 is failed
project.total_failed_tasks++;
}
// Update latest task date
if (task.created_at && task.created_at > project.latest_task_date) {
project.latest_task_date = task.created_at;
}
});
// Calculate averages and sort tasks within each project
projectMap.forEach(project => {
project.average_tokens_per_task = project.task_count > 0
? Math.round(project.total_tokens / project.task_count)
: 0;
// Sort tasks by creation date (newest first)
project.tasks.sort((a, b) => {
const dateA = new Date(a.created_at || 0).getTime();
const dateB = new Date(b.created_at || 0).getTime();
return dateB - dateA;
});
});
// Convert to array and sort by latest task date (newest first)
return Array.from(projectMap.values()).sort((a, b) => {
const dateA = new Date(a.latest_task_date).getTime();
const dateB = new Date(b.latest_task_date).getTime();
return dateB - dateA;
});
};
export const fetchHistoryTasks = async (setTasks: React.Dispatch<React.SetStateAction<any[]>>) => {
try {
@ -8,4 +70,21 @@ export const fetchHistoryTasks = async (setTasks: React.Dispatch<React.SetStateA
console.error("Failed to fetch history tasks:", error);
setTasks([])
}
};
// New function to fetch grouped history tasks
export const fetchGroupedHistoryTasks = async (setProjects: React.Dispatch<React.SetStateAction<ProjectGroup[]>>) => {
try {
const res = await proxyFetchGet(`/api/chat/histories`);
const groupedProjects = groupTasksByProject(res.items);
setProjects(groupedProjects);
} catch (error) {
console.error("Failed to fetch grouped history tasks:", error);
setProjects([]);
}
};
// Utility function to get all tasks from grouped data (for backward compatibility)
export const flattenProjectTasks = (projects: ProjectGroup[]): HistoryTask[] => {
return projects.flatMap(project => project.tasks);
};

View file

@ -3,8 +3,8 @@ import { persist } from 'zustand/middleware';
// Define state types
interface GlobalStore {
history_type: "table" | "list";
setHistoryType: (history_type: "table" | "list") => void;
history_type: "table" | "list" | "grouped";
setHistoryType: (history_type: "table" | "list" | "grouped") => void;
toggleHistoryType: () => void;
}
@ -12,13 +12,16 @@ interface GlobalStore {
const globalStore = create<GlobalStore>()(
persist(
(set) => ({
history_type: "list",
setHistoryType: (history_type: "table" | "list") =>
history_type: "grouped",
setHistoryType: (history_type: "table" | "list" | "grouped") =>
set({ history_type }),
toggleHistoryType: () =>
set((state) => ({
history_type: state.history_type === "table" ? "list" : "table",
})),
set((state) => {
// Cycle through: grouped -> list -> table -> grouped
if (state.history_type === "grouped") return { history_type: "list" };
if (state.history_type === "list") return { history_type: "table" };
return { history_type: "grouped" };
}),
}),
{
name: 'global-storage',

55
src/types/history.d.ts vendored Normal file
View file

@ -0,0 +1,55 @@
// History API types for project-grouped structure
export interface HistoryTask {
id: number;
task_id: string;
project_id: string;
question: string;
language: string;
model_platform: string;
model_type: string;
api_key?: string;
api_url?: string;
max_retries: number;
file_save_path?: string;
installed_mcp?: string;
project_name?: string;
summary?: string;
tokens: number;
status: number;
created_at?: string;
updated_at?: string;
}
export interface ProjectGroup {
project_id: string;
project_name?: string;
total_tokens: number;
task_count: number;
latest_task_date: string;
tasks: HistoryTask[];
// Additional project-level metadata
total_completed_tasks: number;
total_failed_tasks: number;
average_tokens_per_task: number;
}
export interface GroupedHistoryResponse {
projects: ProjectGroup[];
total_projects: number;
total_tasks: number;
total_tokens: number;
}
// Legacy flat response for backward compatibility
export interface FlatHistoryResponse {
items: HistoryTask[];
total: number;
page: number;
size: number;
}
export interface HistoryApiOptions {
grouped?: boolean; // New parameter to control response format
include_tasks?: boolean; // Whether to include individual tasks in groups
}