feat(ai-workspace): 혼합 모드 병렬 그루핑 UI + 카드별 분리
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m3s

- 스텝 사이 화살표에 "병렬로 합치기" 버튼 추가 → 두 스텝을 같은 execution_order 로 묶음
- 병렬 스텝 카드별 분리 버튼 (trash 왼쪽) → 해당 카드만 다음 스텝으로 빼냄
- 병렬 스텝 헤더 amber 배경 +  "병렬 실행" 배지로 시각 차별화
- (순차)/(병렬) 부가 라벨 제거, 깔끔한 "Step N" 표기로 통일
- execution_order 정규화 헬퍼(renormalizeOrders) + 일괄 저장(persistOrders) 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 08:04:24 +09:00
parent 4514275347
commit a6b66ac0d1
@@ -8,7 +8,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Network, Plus, Pencil, Trash2, Loader2, Bot, Database, Globe, FileText, Cpu, Bug, X, Zap, ListOrdered, Shuffle, GripVertical, ArrowDown, Check } from "lucide-react";
import { Network, Plus, Pencil, Trash2, Loader2, Bot, Database, Globe, FileText, Cpu, Bug, X, Zap, ListOrdered, Shuffle, GripVertical, ArrowDown, Check, Split } from "lucide-react";
import { toast } from "sonner";
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
@@ -217,6 +217,58 @@ export default function WorkspacePage() {
} catch { toast.error("커넥터 제거 실패"); }
};
const renormalizeOrders = (members: any[]) => {
const sorted = [...members].sort((a: any, b: any) => a.execution_order - b.execution_order);
const orderMap = new Map<number, number>();
let nextOrder = 1;
for (const m of sorted) {
if (!orderMap.has(m.execution_order)) {
orderMap.set(m.execution_order, nextOrder++);
}
}
return sorted.map((m: any) => ({ ...m, execution_order: orderMap.get(m.execution_order)! }));
};
const persistOrders = async (newMembers: any[]) => {
if (!detailGroup) return;
setDetailGroup({ ...detailGroup, members: newMembers });
try {
await Promise.all(newMembers.map((m: any) =>
aiAgentApi.updateGroupMember(m.id, { execution_order: m.execution_order })
));
} catch {
toast.error("순서 변경 실패");
loadGroupDetail(detailGroup.id);
}
};
const handleMergeSteps = async (orderA: number, orderB: number) => {
if (!detailGroup?.members) return;
const updated = detailGroup.members.map((m: any) =>
m.execution_order === orderB ? { ...m, execution_order: orderA } : m
);
const renormalized = renormalizeOrders(updated);
await persistOrders(renormalized);
toast.success("병렬로 합쳐졌습니다");
};
const handleSplitMember = async (memberId: number) => {
if (!detailGroup?.members) return;
const target = detailGroup.members.find((m: any) => m.id === memberId);
if (!target) return;
const currentOrder = target.execution_order;
const sameStepCount = detailGroup.members.filter((m: any) => m.execution_order === currentOrder).length;
if (sameStepCount <= 1) return;
const updated = detailGroup.members.map((m: any) => {
if (m.id === memberId) return { ...m, execution_order: currentOrder + 1 };
if (m.execution_order > currentOrder) return { ...m, execution_order: m.execution_order + 1 };
return m;
});
const renormalized = renormalizeOrders(updated);
await persistOrders(renormalized);
toast.success("분리되었습니다");
};
const activeAgents = agents.filter((a) => a.status === "active");
return (
@@ -367,20 +419,42 @@ export default function WorkspacePage() {
const isParallel = membersInOrder.length > 1;
return (
<div key={order}>
<div className="mb-1.5 flex items-center gap-1.5">
<div className={`mb-1.5 flex items-center gap-1.5 rounded px-1.5 py-1 ${isParallel ? "border border-amber-200 bg-amber-50 dark:border-amber-900/40 dark:bg-amber-950/20" : ""}`}>
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">{orderIdx + 1}</span>
<span className="text-[10px] text-muted-foreground">
Step {orderIdx + 1} {isParallel ? "(병렬)" : "(순차)"}
</span>
{isParallel && <Zap className="h-3 w-3 text-amber-500" />}
<span className="text-[10px] text-muted-foreground">Step {orderIdx + 1}</span>
{isParallel && (
<span className="flex items-center gap-0.5 text-[10px] font-semibold text-amber-600 dark:text-amber-500">
<Zap className="h-3 w-3" />
</span>
)}
</div>
<div className={isParallel ? "grid grid-cols-2 gap-2 lg:grid-cols-3" : ""}>
{membersInOrder.map((member: any, idx: number) => (
<SortableMemberCard key={member.id} member={member} index={idx} connectors={connectors} onRemove={() => handleRemoveMember(member.id)} onAddConnector={(conn) => handleAddMemberConnector(member.id, conn)} onRemoveConnector={(ci) => handleRemoveMemberConnector(member.id, ci)} />
<SortableMemberCard
key={member.id}
member={member}
index={idx}
connectors={connectors}
onRemove={() => handleRemoveMember(member.id)}
onAddConnector={(conn) => handleAddMemberConnector(member.id, conn)}
onRemoveConnector={(ci) => handleRemoveMemberConnector(member.id, ci)}
onSplit={isParallel ? () => handleSplitMember(member.id) : undefined}
/>
))}
</div>
{orderIdx < sortedOrders.length - 1 && (
<div className="flex justify-center py-1"><ArrowDown className="h-4 w-4 text-primary/30" /></div>
<div className="flex flex-col items-center gap-1 py-1.5">
<ArrowDown className="h-3.5 w-3.5 text-primary/30" />
<button
onClick={() => handleMergeSteps(order, sortedOrders[orderIdx + 1])}
className="flex items-center gap-1 rounded-full border border-dashed border-amber-300 bg-amber-50/50 px-2 py-0.5 text-[10px] text-amber-700 transition-colors hover:border-amber-500 hover:bg-amber-100 dark:border-amber-800 dark:bg-amber-950/20 dark:text-amber-400 dark:hover:bg-amber-900/30"
title="이 두 스텝을 같은 단계(병렬)로 합치기"
>
<Zap className="h-3 w-3" />
</button>
</div>
)}
</div>
);
@@ -552,17 +626,18 @@ interface MemberCardProps {
onRemove: () => void;
onAddConnector: (conn: any) => void;
onRemoveConnector: (idx: number) => void;
onSplit?: () => void;
}
function MemberCard({ member, connectors, onRemove, onAddConnector, onRemoveConnector }: MemberCardProps) {
function MemberCard({ member, connectors, onRemove, onAddConnector, onRemoveConnector, onSplit }: MemberCardProps) {
return (
<div className="rounded-lg border bg-card p-3 transition-all hover:shadow-sm">
<MemberCardContent member={member} connectors={connectors} onRemove={onRemove} onAddConnector={onAddConnector} onRemoveConnector={onRemoveConnector} />
<MemberCardContent member={member} connectors={connectors} onRemove={onRemove} onAddConnector={onAddConnector} onRemoveConnector={onRemoveConnector} onSplit={onSplit} />
</div>
);
}
function SortableMemberCard({ member, index, connectors, onRemove, onAddConnector, onRemoveConnector }: MemberCardProps & { index: number }) {
function SortableMemberCard({ member, index, connectors, onRemove, onAddConnector, onRemoveConnector, onSplit }: MemberCardProps & { index: number }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: String(member.id) });
const style = {
transform: CSS.Transform.toString(transform),
@@ -578,7 +653,7 @@ function SortableMemberCard({ member, index, connectors, onRemove, onAddConnecto
<GripVertical className="h-3.5 w-3.5" />
</button>
<div className="min-w-0 flex-1">
<MemberCardContent member={member} connectors={connectors} onRemove={onRemove} onAddConnector={onAddConnector} onRemoveConnector={onRemoveConnector} />
<MemberCardContent member={member} connectors={connectors} onRemove={onRemove} onAddConnector={onAddConnector} onRemoveConnector={onRemoveConnector} onSplit={onSplit} />
</div>
</div>
</div>
@@ -593,7 +668,7 @@ const CONNECTOR_TYPE_OPTIONS = [
{ type: "file", label: "파일", icon: FileText, color: "text-amber-500" },
];
function MemberCardContent({ member, connectors, onRemove, onAddConnector, onRemoveConnector }: MemberCardProps) {
function MemberCardContent({ member, connectors, onRemove, onAddConnector, onRemoveConnector, onSplit }: MemberCardProps) {
const [pickerOpen, setPickerOpen] = useState(false);
const [selectedType, setSelectedType] = useState<string | null>(null);
const memberConnectors = safeArray<any>(member.connectors);
@@ -613,9 +688,20 @@ function MemberCardContent({ member, connectors, onRemove, onAddConnector, onRem
</div>
<p className="mt-0.5 ml-5 text-[10px] text-muted-foreground font-mono truncate" title={member.agent_model}>{member.agent_model}</p>
</div>
<button className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-destructive hover:bg-destructive/10" onClick={onRemove}>
<Trash2 className="h-3 w-3" />
</button>
<div className="flex shrink-0 items-center gap-0.5">
{onSplit && (
<button
className="flex h-5 w-5 items-center justify-center rounded text-amber-600 hover:bg-amber-50 dark:text-amber-500 dark:hover:bg-amber-950/30"
onClick={onSplit}
title="이 에이전트를 다음 스텝으로 분리"
>
<Split className="h-3 w-3" />
</button>
)}
<button className="flex h-5 w-5 items-center justify-center rounded text-destructive hover:bg-destructive/10" onClick={onRemove}>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
<div className="mt-2 ml-5 flex flex-wrap items-center gap-1">