File size: 5,155 Bytes
0b9dc2e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
import type { Task, TaskContext } from '@agentscope-ai/agentscope/state';
import { Ellipsis, ListX, Loader2, Square, SquareCheck } from 'lucide-react';
import { useState } from 'react';

import { PanelEmpty } from '@/components/panel/PanelEmpty';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/i18n/useI18n';
import { cn } from '@/lib/utils';

interface TaskPanelProps {
	/**

	 * The task context to render. Pass ``null`` when no data is

	 * available yet (renders nothing).

	 */
	tasksContext: TaskContext | null;
	className?: string;
}

/**

 * State icon for a single task row.

 *

 * @param state - The task's current state.

 * @returns An icon element sized for inline display.

 */
function StateIcon({ state }: { state: Task['state'] }) {
	switch (state) {
		case 'completed':
			return <SquareCheck className="size-3 shrink-0" />;
		case 'in_progress':
			return <Loader2 className="size-3 animate-spin shrink-0" />;
		default:
			return <Square className="size-3 shrink-0" />;
	}
}

/**

 * Filters the task list so that only the last 3 of any leading run of

 * completed tasks are shown; earlier ones are replaced by a single

 * ellipsis sentinel.

 *

 * @param tasks - The full ordered task array.

 * @returns An object with the visible task slice and a boolean flag

 *   indicating whether the ellipsis row should be rendered.

 */
function filterTasksWithEllipsis(tasks: Task[]): {
	showEllipsis: boolean;
	visibleTasks: Task[];
} {
	// Count how many consecutive completed tasks appear at the front.
	let consecutiveCompleted = 0;
	for (const task of tasks) {
		if (task.state === 'completed') {
			consecutiveCompleted++;
		} else {
			break;
		}
	}

	const MAX_VISIBLE_COMPLETED = 3;
	if (consecutiveCompleted <= MAX_VISIBLE_COMPLETED) {
		return { showEllipsis: false, visibleTasks: tasks };
	}

	// Discard all but the last MAX_VISIBLE_COMPLETED completed tasks.
	return {
		showEllipsis: true,
		visibleTasks: tasks.slice(consecutiveCompleted - MAX_VISIBLE_COMPLETED),
	};
}

/**

 * Compact, read-only panel listing the agent's current tasks with

 * their status and dependency information.

 *

 * Each row shows ``#id  [icon]  subject  [← blocked by #x, #y]``.

 * The panel header displays a progress summary like ``Tasks (3/5)``.

 * When there are more than 3 leading completed tasks, the earlier ones

 * are collapsed into a single ``…`` separator row.

 *

 * @param tasksContext - The full ``TaskContext`` from ``AgentState``.

 *   ``null`` hides the panel entirely.

 * @param className - The className

 * @returns A panel element, or ``null`` when there are no tasks.

 */
export function TaskPanel({ tasksContext, className }: TaskPanelProps) {
	const { t } = useTranslation();
	const [expanded, setExpanded] = useState(false);

	if (!tasksContext || tasksContext.tasks.length === 0) {
		return (
			<PanelEmpty

				icon={ListX}

				title={t('panel.plan.emptyTitle')}

				description={t('panel.plan.emptyDescription')}

			/>
		);
	}

	const { tasks } = tasksContext;
	const completed = tasks.filter((task) => task.state === 'completed').length;
	const { showEllipsis, visibleTasks } = filterTasksWithEllipsis(tasks);

	const displayedTasks = expanded ? tasks : visibleTasks;

	return (
		<div className={cn('flex flex-col flex-1 min-h-0 text-sm', className)}>

			<div className="flex flex-row items-center gap-x-2 shrink-0 pb-2">

				<Badge variant={'secondary'} className="tracking-wide">

					{t('panel.plan.completed', { count: completed })}

				</Badge>

				<Badge variant={'secondary'} className="tracking-wide">

					{t('panel.plan.total', { count: tasks.length })}

				</Badge>

			</div>



			<ul className="flex flex-col flex-1 min-h-0 gap-y-0.5 text-xs overflow-y-auto">

				{showEllipsis && !expanded && (

					<Button

						size={'xs'}

						variant={'ghost'}

						className="flex items-center justify-center w-full"

						onClick={() => setExpanded(true)}

					>

						<Ellipsis className="size-3 text-muted-foreground" />

					</Button>

				)}

				{displayedTasks.map((task) => (

					<li

						key={task.id}

						className={cn(

							'flex gap-2 rounded px-2 py-1 items-center',

							task.state === 'completed' && 'opacity-60',

						)}

					>

						<StateIcon state={task.state} />

						<div className="flex flex-col min-w-0 gap-0.5">

							<span className="flex items-center gap-1.5">

								<span className="text-xs font-mono text-muted-foreground">

									#{task.id}

								</span>

								<span

									className={cn(

										'truncate ',

										task.state === 'completed' && 'line-through',

									)}

								>

									{task.subject}

								</span>

							</span>

							{task.blocked_by.length > 0 && (

								<span className="text-xs text-muted-foreground">

									← {t('task-panel.blockedBy')}{' '}

									{task.blocked_by.map((id) => `#${id}`).join(', ')}

								</span>

							)}

						</div>

					</li>

				))}

			</ul>

		</div>
	);
}