mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-05-28 01:25:54 +00:00
- Add animate Blocks icon and use for History projects tab - History page horizontal padding px-20 to px-16 - Remove redundant break-words on task card title (overflow-wrap:anywhere) Made-with: Cursor
536 lines
21 KiB
TypeScript
536 lines
21 KiB
TypeScript
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { Progress } from '@/components/ui/progress';
|
|
import { useHost } from '@/host';
|
|
import { TaskItem } from './TaskItem';
|
|
|
|
import { TaskState, TaskStateType } from '@/components/TaskState';
|
|
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
|
|
import { ChatTaskStatus, TaskStatus } from '@/types/constants';
|
|
import {
|
|
ChevronDown,
|
|
Circle,
|
|
CircleCheckBig,
|
|
CircleSlash,
|
|
LoaderCircle,
|
|
Plus,
|
|
TriangleAlert,
|
|
} from 'lucide-react';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
|
|
const TASK_CARD_EXPAND_STORAGE_PREFIX = 'eigent:task-card-expanded';
|
|
|
|
function getTaskCardExpandStorageKey(
|
|
chatId: string | undefined,
|
|
activeTaskId: string | undefined
|
|
): string | null {
|
|
if (!activeTaskId) return null;
|
|
if (chatId)
|
|
return `${TASK_CARD_EXPAND_STORAGE_PREFIX}:${chatId}:${activeTaskId}`;
|
|
return `${TASK_CARD_EXPAND_STORAGE_PREFIX}:${activeTaskId}`;
|
|
}
|
|
|
|
function readStoredTaskCardExpanded(key: string | null): boolean {
|
|
if (!key || typeof window === 'undefined') return false;
|
|
try {
|
|
return sessionStorage.getItem(key) === '1';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function writeStoredTaskCardExpanded(key: string | null, expanded: boolean) {
|
|
if (!key || typeof window === 'undefined') return;
|
|
try {
|
|
sessionStorage.setItem(key, expanded ? '1' : '0');
|
|
} catch {
|
|
/* ignore quota / private mode */
|
|
}
|
|
}
|
|
|
|
interface TaskCardProps {
|
|
taskInfo: any[];
|
|
taskAssigning: Agent[];
|
|
taskRunning: TaskInfo[];
|
|
taskType: 1 | 2 | 3;
|
|
progressValue: number;
|
|
summaryTask: string;
|
|
onAddTask: () => void;
|
|
onUpdateTask: (taskIndex: number, content: string) => void;
|
|
onSaveTask: () => void;
|
|
onDeleteTask: (taskIndex: number) => void;
|
|
clickable?: boolean;
|
|
chatId?: string;
|
|
}
|
|
|
|
export function TaskCard({
|
|
taskInfo,
|
|
taskType,
|
|
taskRunning,
|
|
progressValue,
|
|
summaryTask,
|
|
onAddTask,
|
|
onUpdateTask,
|
|
onSaveTask,
|
|
onDeleteTask,
|
|
clickable = true,
|
|
chatId,
|
|
}: TaskCardProps) {
|
|
const host = useHost();
|
|
const electronAPI = host?.electronAPI;
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
const [contentHeight, setContentHeight] = useState<number | 'auto'>('auto');
|
|
const [selectedState, setSelectedState] = useState<TaskStateType>('all');
|
|
const [filterTasks, setFilterTasks] = useState<any[]>([]);
|
|
|
|
//Get Chatstore and ProjectStore for the active project's task
|
|
const { chatStore, projectStore } = useChatStoreAdapter();
|
|
|
|
// Extract values for dependency arrays (must be before any conditional returns)
|
|
const activeTaskId = chatStore?.activeTaskId as string;
|
|
const activeTask = chatStore?.tasks?.[activeTaskId];
|
|
const activeTaskStatus = activeTask?.status;
|
|
const expandStorageKey = getTaskCardExpandStorageKey(chatId, activeTaskId);
|
|
|
|
const [isExpanded, setIsExpanded] = useState(() =>
|
|
readStoredTaskCardExpanded(
|
|
getTaskCardExpandStorageKey(chatId, activeTaskId)
|
|
)
|
|
);
|
|
|
|
useEffect(() => {
|
|
setIsExpanded(readStoredTaskCardExpanded(expandStorageKey));
|
|
}, [expandStorageKey]);
|
|
|
|
useEffect(() => {
|
|
writeStoredTaskCardExpanded(expandStorageKey, isExpanded);
|
|
}, [expandStorageKey, isExpanded]);
|
|
|
|
useEffect(() => {
|
|
const tasks = taskRunning || [];
|
|
|
|
if (selectedState === 'all') {
|
|
setFilterTasks(tasks);
|
|
} else {
|
|
const newFiltered = tasks.filter((task) => {
|
|
switch (selectedState) {
|
|
case 'done':
|
|
return task.status === TaskStatus.COMPLETED && !task.reAssignTo;
|
|
case 'reassigned':
|
|
return !!task.reAssignTo;
|
|
case 'ongoing':
|
|
return (
|
|
task.status !== TaskStatus.FAILED &&
|
|
task.status !== TaskStatus.COMPLETED &&
|
|
task.status !== TaskStatus.SKIPPED &&
|
|
task.status !== TaskStatus.WAITING &&
|
|
task.status !== TaskStatus.EMPTY &&
|
|
!task.reAssignTo
|
|
);
|
|
case 'pending':
|
|
return (
|
|
(task.status === TaskStatus.SKIPPED ||
|
|
task.status === TaskStatus.WAITING ||
|
|
task.status === TaskStatus.EMPTY) &&
|
|
!task.reAssignTo
|
|
);
|
|
case 'failed':
|
|
return task.status === TaskStatus.FAILED && !task.reAssignTo;
|
|
default:
|
|
return false;
|
|
}
|
|
});
|
|
setFilterTasks(newFiltered);
|
|
}
|
|
}, [selectedState, taskInfo, taskRunning]);
|
|
|
|
// Improved height calculation logic
|
|
useEffect(() => {
|
|
if (!contentRef.current) return;
|
|
|
|
const updateHeight = () => {
|
|
if (contentRef.current) {
|
|
const scrollHeight = contentRef.current.scrollHeight;
|
|
setContentHeight(scrollHeight);
|
|
}
|
|
};
|
|
|
|
// Update height immediately
|
|
updateHeight();
|
|
|
|
// Use ResizeObserver to monitor content changes
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
updateHeight();
|
|
});
|
|
|
|
resizeObserver.observe(contentRef.current);
|
|
|
|
// Update height when taskRunning changes
|
|
const timeoutId = setTimeout(updateHeight, 100);
|
|
|
|
return () => {
|
|
resizeObserver.disconnect();
|
|
clearTimeout(timeoutId);
|
|
};
|
|
}, [taskRunning, isExpanded]);
|
|
|
|
// Handle height updates specifically for expand/collapse state changes
|
|
useEffect(() => {
|
|
if (!contentRef.current || !isExpanded) return;
|
|
|
|
const updateHeightOnExpand = () => {
|
|
if (contentRef.current && isExpanded) {
|
|
// Small delay to ensure DOM is fully rendered
|
|
requestAnimationFrame(() => {
|
|
if (contentRef.current) {
|
|
setContentHeight(contentRef.current.scrollHeight);
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
// Update height immediately when expanded
|
|
updateHeightOnExpand();
|
|
|
|
// Additional delay when expanded to ensure all animations complete
|
|
if (isExpanded) {
|
|
const timeoutId = setTimeout(updateHeightOnExpand, 300);
|
|
return () => clearTimeout(timeoutId);
|
|
}
|
|
}, [isExpanded]);
|
|
|
|
// Early return after all hooks
|
|
if (!chatStore) {
|
|
return <div>Loading...</div>;
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="gap-2 px-sm py-2 flex h-auto w-full flex-col transition-all duration-300">
|
|
<div className="rounded-xl py-sm bg-ds-bg-neutral-default-default relative h-auto w-full overflow-hidden">
|
|
<div className="left-0 top-0 absolute w-full bg-transparent">
|
|
<Progress value={progressValue} className="h-[2px] w-full" />
|
|
</div>
|
|
{summaryTask && (
|
|
<div className="mb-2.5 px-sm text-sm font-bold leading-13">
|
|
{summaryTask.split('|')[0].replace(/"/g, '')}
|
|
</div>
|
|
)}
|
|
|
|
{summaryTask && (
|
|
<div className={`gap-2 px-sm flex items-center justify-between`}>
|
|
<div className="gap-2 flex items-center">
|
|
{taskType === 1 && (
|
|
<TaskState
|
|
all={
|
|
taskInfo.filter((task) => task.content !== '').length || 0
|
|
}
|
|
done={
|
|
taskInfo.filter(
|
|
(task) =>
|
|
task.content !== '' &&
|
|
task.status === TaskStatus.COMPLETED
|
|
).length || 0
|
|
}
|
|
progress={
|
|
taskInfo.filter(
|
|
(task) =>
|
|
task.content !== '' &&
|
|
task.status !== TaskStatus.COMPLETED &&
|
|
task.status !== TaskStatus.FAILED &&
|
|
task.status !== TaskStatus.SKIPPED &&
|
|
task.status !== TaskStatus.WAITING &&
|
|
task.status !== TaskStatus.EMPTY
|
|
).length || 0
|
|
}
|
|
skipped={
|
|
taskInfo.filter(
|
|
(task) =>
|
|
task.content !== '' &&
|
|
(task.status === TaskStatus.SKIPPED ||
|
|
task.status === TaskStatus.WAITING ||
|
|
task.status === TaskStatus.EMPTY)
|
|
).length || 0
|
|
}
|
|
failed={
|
|
taskInfo.filter(
|
|
(task) =>
|
|
task.content !== '' &&
|
|
task.status === TaskStatus.FAILED
|
|
).length || 0
|
|
}
|
|
forceVisible={true}
|
|
clickable={clickable}
|
|
/>
|
|
)}
|
|
{taskType !== 1 && (
|
|
<TaskState
|
|
all={taskRunning?.length || 0}
|
|
done={
|
|
taskRunning?.filter(
|
|
(task) =>
|
|
task.status === TaskStatus.COMPLETED &&
|
|
!task.reAssignTo
|
|
).length || 0
|
|
}
|
|
reAssignTo={
|
|
taskRunning?.filter((task) => task.reAssignTo)?.length ||
|
|
0
|
|
}
|
|
progress={
|
|
taskRunning?.filter(
|
|
(task) =>
|
|
task.status !== TaskStatus.COMPLETED &&
|
|
task.status !== TaskStatus.FAILED &&
|
|
task.status !== TaskStatus.SKIPPED &&
|
|
task.status !== TaskStatus.WAITING &&
|
|
task.status !== TaskStatus.EMPTY &&
|
|
!task.reAssignTo
|
|
).length || 0
|
|
}
|
|
skipped={
|
|
taskRunning?.filter(
|
|
(task) =>
|
|
(task.status === TaskStatus.SKIPPED ||
|
|
task.status === TaskStatus.WAITING ||
|
|
task.status === TaskStatus.EMPTY) &&
|
|
!task.reAssignTo
|
|
).length || 0
|
|
}
|
|
failed={
|
|
taskRunning?.filter(
|
|
(task) =>
|
|
task.status === TaskStatus.FAILED && !task.reAssignTo
|
|
).length || 0
|
|
}
|
|
forceVisible={true}
|
|
selectedState={selectedState}
|
|
onStateChange={setSelectedState}
|
|
clickable={clickable}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="ease-in-out transition-all duration-300">
|
|
{taskType === 1 && (
|
|
<Button variant="ghost" size="icon" onClick={onAddTask}>
|
|
<Plus size={16} />
|
|
</Button>
|
|
)}
|
|
{taskType === 2 && (
|
|
<div className="gap-2 animate-in fade-in-0 slide-in-from-right-2 flex items-center duration-300">
|
|
{isExpanded && (
|
|
<div className="text-xs font-medium leading-17 text-ds-text-neutral-subtle-default">
|
|
{taskRunning?.filter(
|
|
(task) =>
|
|
task.status === TaskStatus.COMPLETED ||
|
|
task.status === TaskStatus.FAILED
|
|
).length || 0}
|
|
/{taskRunning?.length || 0}
|
|
</div>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setIsExpanded((prev) => !prev)}
|
|
>
|
|
<ChevronDown
|
|
size={16}
|
|
className={`transition-transform duration-300 ${
|
|
isExpanded ? 'rotate-180' : ''
|
|
}`}
|
|
/>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="relative">
|
|
{taskType === 1 && (
|
|
<div className="mt-sm px-sm ease-out animate-in fade-in-0 slide-in-from-bottom-4 flex flex-col duration-500">
|
|
{taskInfo.map((task, taskIndex) => (
|
|
<div
|
|
key={`task-${taskIndex}`}
|
|
className="animate-in fade-in-0 slide-in-from-left-2 duration-300"
|
|
>
|
|
<TaskItem
|
|
taskInfo={task}
|
|
taskIndex={taskIndex}
|
|
onUpdate={(content) => onUpdateTask(taskIndex, content)}
|
|
onSave={() => onSaveTask()}
|
|
onDelete={() => onDeleteTask(taskIndex)}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{taskType === 2 && (
|
|
<div
|
|
ref={contentRef}
|
|
className="ease-in-out overflow-hidden transition-all duration-300"
|
|
style={{
|
|
height: isExpanded ? contentHeight : 0,
|
|
opacity: isExpanded ? 1 : 0,
|
|
}}
|
|
>
|
|
<div className="mt-sm gap-2 px-2 flex flex-col">
|
|
{filterTasks.map((task: TaskInfo) => {
|
|
return (
|
|
<div
|
|
onClick={() => {
|
|
if (task.agent) {
|
|
// Switch to the chatStore that owns this task card (for multi-turn conversations)
|
|
if (chatId && projectStore.activeProjectId) {
|
|
const activeProjectId =
|
|
projectStore.activeProjectId;
|
|
const activeChatStore =
|
|
projectStore.getActiveChatStore();
|
|
const currentChatId = activeChatStore
|
|
? Object.keys(
|
|
projectStore.projects[activeProjectId]
|
|
.chatStores
|
|
).find(
|
|
(id) =>
|
|
projectStore.projects[activeProjectId]
|
|
.chatStores[id] === activeChatStore
|
|
)
|
|
: null;
|
|
|
|
// Only switch if this is a different chat
|
|
if (currentChatId && currentChatId !== chatId) {
|
|
projectStore.setActiveChatStore(
|
|
activeProjectId,
|
|
chatId
|
|
);
|
|
}
|
|
}
|
|
|
|
// Set the active workspace and agent
|
|
chatStore.setActiveWorkspace(
|
|
chatStore.activeTaskId as string,
|
|
'workflow'
|
|
);
|
|
chatStore.setActiveAgent(
|
|
chatStore.activeTaskId as string,
|
|
task.agent?.agent_id
|
|
);
|
|
electronAPI?.hideAllWebview();
|
|
}
|
|
}}
|
|
key={`taskList-${task.id}`}
|
|
className={`gap-2 rounded-lg px-sm py-sm ease-in-out animate-in fade-in-0 slide-in-from-left-2 flex transition-all duration-300 ${
|
|
task.status === TaskStatus.COMPLETED
|
|
? 'bg-ds-bg-completed-subtle-default'
|
|
: task.status === TaskStatus.FAILED
|
|
? 'bg-ds-bg-error-subtle-default'
|
|
: task.status === TaskStatus.RUNNING
|
|
? 'bg-ds-bg-running-subtle-default'
|
|
: task.status === TaskStatus.BLOCKED
|
|
? 'bg-ds-bg-blocked-subtle-default'
|
|
: task.status === TaskStatus.SKIPPED ||
|
|
task.status === TaskStatus.WAITING ||
|
|
task.status === TaskStatus.EMPTY
|
|
? 'bg-ds-bg-pending-subtle-default'
|
|
: 'bg-ds-bg-running-subtle-default'
|
|
} cursor-pointer border border-solid border-transparent ${
|
|
task.status === TaskStatus.COMPLETED
|
|
? 'hover:border-ds-border-status-completed-default-focus'
|
|
: task.status === TaskStatus.FAILED
|
|
? 'hover:border-ds-border-status-error-default-focus'
|
|
: task.status === TaskStatus.RUNNING
|
|
? 'hover:border-ds-border-status-running-default-focus'
|
|
: task.status === TaskStatus.BLOCKED
|
|
? 'hover:border-ds-border-status-blocked-default-focus'
|
|
: task.status === TaskStatus.SKIPPED ||
|
|
task.status === TaskStatus.WAITING ||
|
|
task.status === TaskStatus.EMPTY
|
|
? 'hover:border-ds-border-status-pending-default-hover'
|
|
: 'border-transparent'
|
|
} `}
|
|
>
|
|
<div className="pt-0.5">
|
|
{task.status === TaskStatus.RUNNING && (
|
|
<LoaderCircle
|
|
size={16}
|
|
className={`text-ds-icon-information-default-default ${
|
|
activeTaskStatus === ChatTaskStatus.RUNNING &&
|
|
'animate-spin'
|
|
} `}
|
|
/>
|
|
)}
|
|
{task.status === TaskStatus.SKIPPED && (
|
|
<LoaderCircle
|
|
size={16}
|
|
className="text-ds-icon-status-pending-default-default"
|
|
/>
|
|
)}
|
|
{task.status === TaskStatus.COMPLETED && (
|
|
<CircleCheckBig
|
|
size={16}
|
|
className="text-ds-icon-success-default-default"
|
|
/>
|
|
)}
|
|
{task.status === TaskStatus.FAILED && (
|
|
<CircleSlash
|
|
size={16}
|
|
className="text-ds-icon-caution-default-default"
|
|
/>
|
|
)}
|
|
{task.status === TaskStatus.BLOCKED && (
|
|
<TriangleAlert
|
|
size={16}
|
|
className="text-ds-icon-warning-default-default"
|
|
/>
|
|
)}
|
|
{(task.status === TaskStatus.EMPTY ||
|
|
task.status === TaskStatus.WAITING) && (
|
|
<Circle
|
|
size={16}
|
|
className="text-ds-icon-status-pending-default-default"
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex flex-1 flex-col items-start justify-center">
|
|
<div
|
|
className={`min-w-0 w-full [overflow-wrap:anywhere] whitespace-pre-line ${
|
|
task.status === TaskStatus.FAILED
|
|
? 'text-ds-text-caution-default-default'
|
|
: task.status === TaskStatus.BLOCKED
|
|
? 'text-ds-text-warning-default-default'
|
|
: task.status === TaskStatus.SKIPPED ||
|
|
task.status === TaskStatus.WAITING ||
|
|
task.status === TaskStatus.EMPTY
|
|
? 'text-ds-text-status-pending-default-default'
|
|
: 'text-ds-text-neutral-default-default'
|
|
} text-sm font-medium leading-13`}
|
|
>
|
|
{task.content}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|