From 6a9d9e34520667da42bc34ec54c92b59e7f01311 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Wed, 4 Mar 2026 05:28:11 +0100 Subject: [PATCH] feat: Redesign task and phase dependency display in plans tab Replace plain text dependency indicators with visual, status-aware components: - New DependencyChip/PhaseNumberBadge components with status-colored styling - Sidebar shows compact numbered circles for phase deps instead of text - Detail panel uses bordered cards with phase badges and status indicators - Task dependency callout bars with resolved/total counters - Collapse mechanism for tasks with 3+ dependencies (+N more button) - Full dark mode support via semantic status tokens --- apps/web/src/components/DependencyChip.tsx | 97 +++++++++++++++++++ .../src/components/DependencyIndicator.tsx | 88 ++++++++++++++++- apps/web/src/components/ExecutionTab.tsx | 16 +-- .../components/execution/PhaseDetailPanel.tsx | 83 ++++++++++------ .../components/execution/PhaseSidebarItem.tsx | 29 +++++- 5 files changed, 266 insertions(+), 47 deletions(-) create mode 100644 apps/web/src/components/DependencyChip.tsx diff --git a/apps/web/src/components/DependencyChip.tsx b/apps/web/src/components/DependencyChip.tsx new file mode 100644 index 0000000..34989be --- /dev/null +++ b/apps/web/src/components/DependencyChip.tsx @@ -0,0 +1,97 @@ +import { X } from "lucide-react"; +import { StatusDot, mapEntityStatus, type StatusVariant } from "@/components/StatusDot"; +import { cn } from "@/lib/utils"; + +// --------------------------------------------------------------------------- +// PhaseNumberBadge — small numbered circle colored by phase status +// --------------------------------------------------------------------------- + +const badgeStyles: Record = { + active: "bg-status-active-bg text-status-active-fg border-status-active-border", + success: "bg-status-success-bg text-status-success-fg border-status-success-border", + warning: "bg-status-warning-bg text-status-warning-fg border-status-warning-border", + error: "bg-status-error-bg text-status-error-fg border-status-error-border", + neutral: "bg-muted text-muted-foreground border-border", + urgent: "bg-status-urgent-bg text-status-urgent-fg border-status-urgent-border", +}; + +interface PhaseNumberBadgeProps { + index: number; + status: string; + className?: string; +} + +export function PhaseNumberBadge({ index, status, className }: PhaseNumberBadgeProps) { + const variant = mapEntityStatus(status); + return ( + + {index} + + ); +} + +// --------------------------------------------------------------------------- +// DependencyChip — compact pill showing dependency name + status dot +// --------------------------------------------------------------------------- + +const chipBorderColor: Record = { + active: "border-status-active-border/60", + success: "border-status-success-border/60", + warning: "border-status-warning-border/60", + error: "border-status-error-border/60", + neutral: "border-border", + urgent: "border-status-urgent-border/60", +}; + +interface DependencyChipProps { + name: string; + status: string; + size?: "xs" | "sm"; + className?: string; + onRemove?: () => void; +} + +export function DependencyChip({ + name, + status, + size = "sm", + className, + onRemove, +}: DependencyChipProps) { + const variant = mapEntityStatus(status); + + return ( + + + + {name} + + {onRemove && ( + + )} + + ); +} diff --git a/apps/web/src/components/DependencyIndicator.tsx b/apps/web/src/components/DependencyIndicator.tsx index f4ecdfb..14a2e3c 100644 --- a/apps/web/src/components/DependencyIndicator.tsx +++ b/apps/web/src/components/DependencyIndicator.tsx @@ -1,3 +1,6 @@ +import { useState } from "react"; +import { ArrowUp, ChevronDown, ChevronRight } from "lucide-react"; +import { StatusDot, mapEntityStatus } from "@/components/StatusDot"; import { cn } from "@/lib/utils"; interface DependencyItem { @@ -11,18 +14,97 @@ interface DependencyIndicatorProps { className?: string; } +const MAX_VISIBLE = 3; + export function DependencyIndicator({ blockedBy, type: _type, className, }: DependencyIndicatorProps) { + const [expanded, setExpanded] = useState(false); if (blockedBy.length === 0) return null; - const names = blockedBy.map((item) => item.name).join(", "); + const allResolved = blockedBy.every( + (item) => mapEntityStatus(item.status) === "success", + ); + + const resolvedCount = blockedBy.filter( + (item) => mapEntityStatus(item.status) === "success", + ).length; + + const shouldCollapse = blockedBy.length > MAX_VISIBLE; + const visibleItems = shouldCollapse && !expanded + ? blockedBy.slice(0, MAX_VISIBLE) + : blockedBy; + const hiddenCount = blockedBy.length - MAX_VISIBLE; return ( -
- ^ blocked by: {names} +
+
+ + + blocked by {resolvedCount}/{blockedBy.length} + +
+
+ {visibleItems.map((item, idx) => ( + + + {item.name} + + ))} + {shouldCollapse && !expanded && ( + + )} + {shouldCollapse && expanded && ( + + )} +
); } diff --git a/apps/web/src/components/ExecutionTab.tsx b/apps/web/src/components/ExecutionTab.tsx index 7250feb..bedd933 100644 --- a/apps/web/src/components/ExecutionTab.tsx +++ b/apps/web/src/components/ExecutionTab.tsx @@ -9,7 +9,7 @@ import { TaskModal, type PhaseData, } from "@/components/execution"; -import { PhaseSidebarItem } from "@/components/execution/PhaseSidebarItem"; +import { PhaseSidebarItem, type PhaseDependencyInfo } from "@/components/execution/PhaseSidebarItem"; import { PhaseDetailPanel, PhaseDetailEmpty, @@ -39,15 +39,17 @@ export function ExecutionTab({ [phases, dependencyEdges], ); - // Build dependency name map from bulk edges - const depNamesByPhase = useMemo(() => { - const map = new Map(); + // Build dependency info map from bulk edges (includes status for visual indicators) + const depInfoByPhase = useMemo(() => { + const map = new Map(); const phaseIndex = new Map(sortedPhases.map((p, i) => [p.id, i + 1])); + const phaseStatus = new Map(sortedPhases.map((p) => [p.id, p.status])); for (const edge of dependencyEdges) { const depIdx = phaseIndex.get(edge.dependsOnPhaseId); - if (!depIdx) continue; + const depStatus = phaseStatus.get(edge.dependsOnPhaseId); + if (!depIdx || !depStatus) continue; const existing = map.get(edge.phaseId) ?? []; - existing.push(`Phase ${depIdx}`); + existing.push({ displayIndex: depIdx, status: depStatus }); map.set(edge.phaseId, existing); } return map; @@ -235,7 +237,7 @@ export function ExecutionTab({ taskCount={ taskCountsByPhase[phase.id] ?? { complete: 0, total: 0 } } - dependencies={depNamesByPhase.get(phase.id) ?? []} + dependencies={depInfoByPhase.get(phase.id) ?? []} isSelected={phase.id === activePhaseId} onClick={() => setSelectedPhaseId(phase.id)} detailAgent={detailAgentByPhase.get(phase.id) ?? null} diff --git a/apps/web/src/components/execution/PhaseDetailPanel.tsx b/apps/web/src/components/execution/PhaseDetailPanel.tsx index 5b05f0d..99f3995 100644 --- a/apps/web/src/components/execution/PhaseDetailPanel.tsx +++ b/apps/web/src/components/execution/PhaseDetailPanel.tsx @@ -3,6 +3,8 @@ import { GitBranch, Loader2, MoreHorizontal, Plus, Sparkles, Trash2, X } from "l import { toast } from "sonner"; import { trpc } from "@/lib/trpc"; import { StatusBadge } from "@/components/StatusBadge"; +import { mapEntityStatus } from "@/components/StatusDot"; +import { PhaseNumberBadge } from "@/components/DependencyChip"; import { TaskRow, type SerializedTask } from "@/components/TaskRow"; import { PhaseContentEditor } from "@/components/editor/PhaseContentEditor"; import { ChangeSetBanner } from "@/components/ChangeSetBanner"; @@ -16,6 +18,7 @@ import { } from "@/components/ui/dropdown-menu"; import { sortByPriorityAndQueueTime } from "@codewalk-district/shared"; import { useExecutionContext, type FlatTaskEntry } from "./ExecutionContext"; +import { cn } from "@/lib/utils"; interface PhaseDetailPanelProps { phase: { @@ -302,6 +305,11 @@ export function PhaseDetailPanel({

Dependencies

+ {resolvedDeps.length > 0 && ( + + {resolvedDeps.filter((d) => d.status === "completed").length}/{resolvedDeps.length} resolved + + )} {availableDeps.length > 0 && ( @@ -330,39 +338,50 @@ export function PhaseDetailPanel({ {resolvedDeps.length === 0 ? (

No dependencies

) : ( -
- {resolvedDeps.map((dep) => ( -
- + {resolvedDeps.map((dep, idx) => { + const variant = mapEntityStatus(dep.status); + const borderColor = { + 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", + }[variant]; + + return ( +
- {dep.status === "completed" ? "\u25CF" : "\u25CB"} - - - Phase {allDisplayIndices.get(dep.id) ?? "?"}: {dep.name} - - - -
- ))} + + + {dep.name} + + + +
+ ); + })}
)}
diff --git a/apps/web/src/components/execution/PhaseSidebarItem.tsx b/apps/web/src/components/execution/PhaseSidebarItem.tsx index 2aa7567..ff6e73b 100644 --- a/apps/web/src/components/execution/PhaseSidebarItem.tsx +++ b/apps/web/src/components/execution/PhaseSidebarItem.tsx @@ -1,7 +1,13 @@ -import { Loader2 } from "lucide-react"; +import { ArrowUp, Loader2 } from "lucide-react"; import { StatusBadge } from "@/components/StatusBadge"; +import { PhaseNumberBadge } from "@/components/DependencyChip"; import { cn } from "@/lib/utils"; +export interface PhaseDependencyInfo { + displayIndex: number; + status: string; +} + interface PhaseSidebarItemProps { phase: { id: string; @@ -10,7 +16,7 @@ interface PhaseSidebarItemProps { }; displayIndex: number; taskCount: { complete: number; total: number }; - dependencies: string[]; + dependencies: PhaseDependencyInfo[]; isSelected: boolean; onClick: () => void; detailAgent?: { status: string } | null; @@ -52,6 +58,10 @@ export function PhaseSidebarItem({ ); } + const sortedDeps = [...dependencies].sort( + (a, b) => a.displayIndex - b.displayIndex, + ); + return (