Files
LangBot/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx
Junyan Qin 3b4c455813 fix(web): distinct extension-format icons (plugin/mcp/skill)
The format filter used Wrench/AudioWaveform/Book for plugin/mcp/skill,
which collided with the plugin-component icons (Tool/EventListener/
KnowledgeEngine) shown right below. Switch formats to Puzzle/Server/
Sparkles — matching the canonical getTypeIcon used by the detail badges
— across the market filter, installed filter, install-queue map and
install-progress dialog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:34:23 +08:00

240 lines
6.9 KiB
TypeScript

import React from 'react';
import { useTranslation } from 'react-i18next';
import { Progress } from '@/components/ui/progress';
import {
Download,
Package,
CheckCircle2,
XCircle,
Loader2,
X,
ListTodo,
Puzzle,
Server,
Sparkles,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Badge } from '@/components/ui/badge';
import {
usePluginInstallTasks,
InstallStage,
PluginInstallTask,
} from './PluginInstallTaskContext';
import { cn } from '@/lib/utils';
const STAGE_ICONS: Record<string, React.ElementType> = {
[InstallStage.DOWNLOADING]: Download,
[InstallStage.INSTALLING_DEPS]: Package,
[InstallStage.DONE]: CheckCircle2,
[InstallStage.ERROR]: XCircle,
};
const EXTENSION_TYPE_ICONS: Record<string, React.ElementType> = {
plugin: Puzzle,
mcp: Server,
skill: Sparkles,
};
function TaskQueueItem({
task,
onClick,
onRemove,
}: {
task: PluginInstallTask;
onClick: () => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const isDone = task.stage === InstallStage.DONE;
const isError = task.stage === InstallStage.ERROR;
const isRunning = !isDone && !isError;
const StageIcon = STAGE_ICONS[task.stage] || Download;
const TypeIcon = EXTENSION_TYPE_ICONS[task.extensionType] || Puzzle;
const getTypeBadgeClass = () => {
switch (task.extensionType) {
case 'mcp':
return 'border-sky-500 text-sky-600 dark:border-sky-400 dark:text-sky-300';
case 'skill':
return 'border-emerald-500 text-emerald-600 dark:border-emerald-400 dark:text-emerald-300';
default:
return 'border-violet-500 text-violet-600 dark:border-violet-400 dark:text-violet-300';
}
};
const getTypeLabel = () => {
switch (task.extensionType) {
case 'mcp':
return 'MCP';
case 'skill':
return t('common.skill');
default:
return t('market.typePlugin');
}
};
const getInstallCompleteMessage = () => {
switch (task.extensionType) {
case 'mcp':
return t('plugins.installProgress.installCompleteMCP');
case 'skill':
return t('plugins.installProgress.installCompleteSkill');
default:
return t('plugins.installProgress.installCompletePlugin');
}
};
const stageLabel = (() => {
switch (task.stage) {
case InstallStage.DOWNLOADING:
return t('plugins.installProgress.downloading');
case InstallStage.INSTALLING_DEPS:
return t('plugins.installProgress.installingDeps');
case InstallStage.DONE:
return isDone
? getInstallCompleteMessage()
: t('plugins.installProgress.completed');
case InstallStage.ERROR:
return t('plugins.installProgress.failed');
default:
return '';
}
})();
return (
<div
className="flex items-center gap-2.5 px-3 py-2 rounded-lg hover:bg-muted/60 cursor-pointer transition-colors group"
onClick={onClick}
>
<div
className={cn(
'flex items-center justify-center w-7 h-7 rounded-full shrink-0',
isDone &&
'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400',
isError &&
'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',
isRunning &&
'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400',
)}
>
{isRunning ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<StageIcon className="w-3.5 h-3.5" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="text-sm font-medium truncate">{task.pluginName}</div>
<Badge
variant="outline"
className={cn(
'text-[0.6rem] px-1 py-0 flex-shrink-0',
getTypeBadgeClass(),
)}
>
<TypeIcon className="w-3 h-3 mr-0.5" />
{getTypeLabel()}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{stageLabel}</span>
{isRunning && (
<span className="text-xs text-muted-foreground">
{task.overallProgress}%
</span>
)}
</div>
{isRunning && (
<Progress value={task.overallProgress} className="h-1 mt-1" />
)}
</div>
{(isDone || isError) && (
<Button
variant="ghost"
size="icon"
className="w-6 h-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<X className="w-3 h-3" />
</Button>
)}
</div>
);
}
export default function PluginInstallTaskQueue() {
const { t } = useTranslation();
const { tasks, setSelectedTaskId, removeTask, clearCompletedTasks } =
usePluginInstallTasks();
const runningCount = tasks.filter(
(t) => t.stage !== InstallStage.DONE && t.stage !== InstallStage.ERROR,
).length;
const hasCompleted = tasks.some(
(t) => t.stage === InstallStage.DONE || t.stage === InstallStage.ERROR,
);
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="relative px-4 py-4 cursor-pointer">
<ListTodo className="w-4 h-4 mr-2" />
{t('plugins.installProgress.taskQueue')}
{runningCount > 0 && (
<Badge
variant="default"
className="ml-2 h-5 min-w-5 px-1.5 text-xs"
>
{runningCount}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[340px] p-2" align="end">
<div className="flex items-center justify-between px-2 py-1.5 mb-1">
<span className="text-sm font-semibold">
{t('plugins.installProgress.taskQueue')}
</span>
{hasCompleted && (
<Button
variant="ghost"
size="sm"
className="h-6 text-xs px-2"
onClick={clearCompletedTasks}
>
{t('plugins.installProgress.clearCompleted')}
</Button>
)}
</div>
<div className="max-h-[300px] overflow-y-auto space-y-0.5">
{tasks.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
{t('plugins.installProgress.noTasks')}
</div>
) : (
tasks.map((task) => (
<TaskQueueItem
key={task.id}
task={task}
onClick={() => setSelectedTaskId(task.id)}
onRemove={() => removeTask(task.id)}
/>
))
)}
</div>
</PopoverContent>
</Popover>
);
}