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:
Lukas May
2026-03-04 13:07:39 +01:00
parent 029b5bf0f6
commit 685b2cb4ec
4 changed files with 82 additions and 18 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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}

View File

@@ -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" && (