feat: Refine pipeline UI — progress bars, status borders, collapsible tasks
- Wider phase cards (w-64 → w-72) to reduce task name truncation
- Left border accent colored by phase status for quick visual scanning
- Task count (completed/total) in phase header
- Thin progress bar below header showing completion percentage
- Collapsible task lists: phases with >5 tasks collapse with "N more" toggle
- Blocked-by count styled as a pill badge ("3 deps" instead of "blocked by 3")
- Dashed column connectors with better spacing between pipeline stages
This commit is contained in:
@@ -43,9 +43,9 @@ export function PipelineGraph({ columns, tasksByPhase, taskDepsByPhase, dependen
|
||||
<div key={column.depth} className="flex items-start">
|
||||
{/* Connector arrow between columns */}
|
||||
{idx > 0 && (
|
||||
<div className="flex items-center self-center py-4">
|
||||
<div className="h-px w-6 bg-border" />
|
||||
<div className="h-0 w-0 border-y-[4px] border-l-[6px] border-y-transparent border-l-border" />
|
||||
<div className="flex items-center self-center px-1 py-4">
|
||||
<div className="h-px w-8 border-t border-dashed border-border" />
|
||||
<div className="h-0 w-0 border-y-[3px] border-l-[5px] border-y-transparent border-l-muted-foreground/40" />
|
||||
</div>
|
||||
)}
|
||||
<PipelineStageColumn
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from "react";
|
||||
import { Loader2, Play } from "lucide-react";
|
||||
import { StatusDot } from "@/components/StatusDot";
|
||||
import { useMemo, useState } from "react";
|
||||
import { ChevronDown, Loader2, Play } from "lucide-react";
|
||||
import { StatusDot, mapEntityStatus } from "@/components/StatusDot";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
topologicalSortPhases,
|
||||
type DependencyEdge,
|
||||
@@ -26,9 +27,21 @@ interface PipelinePhaseGroupProps {
|
||||
detailAgent?: DetailAgentInfo | null;
|
||||
}
|
||||
|
||||
const COLLAPSE_THRESHOLD = 5;
|
||||
|
||||
const statusBorderColor: Record<string, string> = {
|
||||
active: "border-l-status-active-dot",
|
||||
success: "border-l-status-success-dot",
|
||||
warning: "border-l-status-warning-dot",
|
||||
error: "border-l-status-error-dot",
|
||||
neutral: "border-l-border",
|
||||
urgent: "border-l-status-urgent-dot",
|
||||
};
|
||||
|
||||
export function PipelinePhaseGroup({ phase, tasks, taskDepsRaw, isBlocked, detailAgent }: PipelinePhaseGroupProps) {
|
||||
const approvePhase = trpc.approvePhase.useMutation();
|
||||
const queuePhase = trpc.queuePhase.useMutation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Sort tasks topologically by dependency order
|
||||
const { sorted, blockedByCountMap } = useMemo(() => {
|
||||
@@ -53,6 +66,10 @@ export function PipelinePhaseGroup({ phase, tasks, taskDepsRaw, isBlocked, detai
|
||||
return { sorted: sortedTasks, blockedByCountMap: countMap };
|
||||
}, [tasks, taskDepsRaw]);
|
||||
|
||||
const completedCount = tasks.filter((t) => t.status === "completed").length;
|
||||
const totalCount = tasks.length;
|
||||
const progressPct = totalCount > 0 ? (completedCount / totalCount) * 100 : 0;
|
||||
|
||||
const hasNonDetailTasks = tasks.length > 0;
|
||||
const canExecute =
|
||||
(phase.status === "approved" || (phase.status === "pending" && hasNonDetailTasks)) &&
|
||||
@@ -64,14 +81,32 @@ export function PipelinePhaseGroup({ phase, tasks, taskDepsRaw, isBlocked, detai
|
||||
detailAgent?.status === "waiting_for_input";
|
||||
const detailDone = detailAgent?.status === "idle";
|
||||
|
||||
const variant = mapEntityStatus(phase.status);
|
||||
const borderClass = statusBorderColor[variant] ?? statusBorderColor.neutral;
|
||||
|
||||
// Collapsible logic
|
||||
const needsCollapse = sorted.length > COLLAPSE_THRESHOLD;
|
||||
const visibleTasks = needsCollapse && !expanded
|
||||
? sorted.slice(0, COLLAPSE_THRESHOLD)
|
||||
: sorted;
|
||||
const hiddenCount = sorted.length - COLLAPSE_THRESHOLD;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<div className={cn(
|
||||
"rounded-lg border border-border border-l-2 bg-card overflow-hidden",
|
||||
borderClass,
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-muted/30">
|
||||
<StatusDot status={phase.status} size="sm" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{phase.name}
|
||||
</span>
|
||||
{totalCount > 0 && (
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
|
||||
{completedCount}/{totalCount}
|
||||
</span>
|
||||
)}
|
||||
{canExecute && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
@@ -93,6 +128,21 @@ export function PipelinePhaseGroup({ phase, tasks, taskDepsRaw, isBlocked, detai
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{totalCount > 0 && (
|
||||
<div className="h-0.5 bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full transition-all duration-500 ease-out",
|
||||
completedCount === totalCount
|
||||
? "bg-status-success-dot"
|
||||
: "bg-status-active-dot",
|
||||
)}
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tasks */}
|
||||
<div className="py-1">
|
||||
{isDetailing ? (
|
||||
@@ -105,13 +155,27 @@ export function PipelinePhaseGroup({ phase, tasks, taskDepsRaw, isBlocked, detai
|
||||
) : sorted.length === 0 ? (
|
||||
<p className="px-3 py-1 text-xs text-muted-foreground">No tasks</p>
|
||||
) : (
|
||||
sorted.map((task) => (
|
||||
<PipelineTaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
blockedByCount={blockedByCountMap.get(task.id) ?? 0}
|
||||
/>
|
||||
))
|
||||
<>
|
||||
{visibleTasks.map((task) => (
|
||||
<PipelineTaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
blockedByCount={blockedByCountMap.get(task.id) ?? 0}
|
||||
/>
|
||||
))}
|
||||
{needsCollapse && (
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex w-full items-center gap-1 px-3 py-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronDown className={cn(
|
||||
"h-3 w-3 transition-transform",
|
||||
expanded && "rotate-180",
|
||||
)} />
|
||||
{expanded ? "Show less" : `${hiddenCount} more task${hiddenCount === 1 ? "" : "s"}`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ interface PipelineStageColumnProps {
|
||||
|
||||
export function PipelineStageColumn({ phases, tasksByPhase, taskDepsByPhase, blockedPhaseIds, detailAgentByPhase }: PipelineStageColumnProps) {
|
||||
return (
|
||||
<div className="flex w-64 shrink-0 flex-col gap-3">
|
||||
<div className="flex w-72 shrink-0 flex-col gap-3">
|
||||
{phases.map((phase) => (
|
||||
<PipelinePhaseGroup
|
||||
key={phase.id}
|
||||
|
||||
@@ -34,8 +34,8 @@ export function PipelineTaskCard({ task, blockedByCount = 0 }: PipelineTaskCardP
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-xs">{task.name}</span>
|
||||
{blockedByCount > 0 && (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
blocked by {blockedByCount}
|
||||
<span className="shrink-0 rounded-full bg-muted px-1.5 py-px text-[10px] text-muted-foreground">
|
||||
{blockedByCount} dep{blockedByCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
)}
|
||||
{task.status === "pending" && (
|
||||
|
||||
Reference in New Issue
Block a user