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
This commit is contained in:
Lukas May
2026-03-04 05:28:11 +01:00
parent 9e7c246280
commit 6a9d9e3452
5 changed files with 266 additions and 47 deletions

View File

@@ -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<StatusVariant, string> = {
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 (
<span
className={cn(
"inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full border px-1 font-mono text-[9px] font-bold leading-none",
badgeStyles[variant],
className,
)}
title={`Phase ${index} (${status})`}
>
{index}
</span>
);
}
// ---------------------------------------------------------------------------
// DependencyChip — compact pill showing dependency name + status dot
// ---------------------------------------------------------------------------
const chipBorderColor: Record<StatusVariant, string> = {
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 (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border bg-background/80",
chipBorderColor[variant],
size === "xs" ? "px-1.5 py-px text-[10px]" : "px-2 py-0.5 text-xs",
className,
)}
>
<StatusDot status={status} size="sm" />
<span className={cn("truncate", size === "xs" ? "max-w-[80px]" : "max-w-[140px]")}>
{name}
</span>
{onRemove && (
<button
className="ml-0.5 rounded-full p-px text-muted-foreground transition-colors hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
title="Remove dependency"
>
<X className="h-2.5 w-2.5" />
</button>
)}
</span>
);
}

View File

@@ -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 (
<div className={cn("pl-8 text-sm text-status-warning-fg", className)}>
<span className="font-mono">^</span> blocked by: {names}
<div
className={cn(
"rounded-md border-l-2 py-1 pl-2.5 pr-2",
allResolved
? "border-l-status-success-border/70 bg-status-success-bg/30"
: "border-l-status-warning-border/70 bg-status-warning-bg/30",
className,
)}
>
<div className="flex items-center gap-1.5">
<ArrowUp
className={cn(
"h-3 w-3 shrink-0",
allResolved ? "text-status-success-fg/50" : "text-status-warning-fg/50",
)}
/>
<span
className={cn(
"text-[10px] font-medium uppercase tracking-wide",
allResolved ? "text-status-success-fg/60" : "text-status-warning-fg/60",
)}
>
blocked by {resolvedCount}/{blockedBy.length}
</span>
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-1 pl-[18px]">
{visibleItems.map((item, idx) => (
<span
key={`${item.name}-${idx}`}
className="inline-flex items-center gap-1 rounded-full border border-border/40 bg-background/60 px-1.5 py-px text-[11px]"
>
<StatusDot status={item.status} size="sm" />
<span className="max-w-[160px] truncate">{item.name}</span>
</span>
))}
{shouldCollapse && !expanded && (
<button
className={cn(
"inline-flex items-center gap-0.5 rounded-full px-1.5 py-px text-[10px] font-medium transition-colors hover:bg-accent",
allResolved ? "text-status-success-fg/70" : "text-status-warning-fg/70",
)}
onClick={(e) => {
e.stopPropagation();
setExpanded(true);
}}
>
+{hiddenCount} more
<ChevronRight className="h-2.5 w-2.5" />
</button>
)}
{shouldCollapse && expanded && (
<button
className={cn(
"inline-flex items-center gap-0.5 rounded-full px-1.5 py-px text-[10px] font-medium transition-colors hover:bg-accent",
allResolved ? "text-status-success-fg/70" : "text-status-warning-fg/70",
)}
onClick={(e) => {
e.stopPropagation();
setExpanded(false);
}}
>
show less
<ChevronDown className="h-2.5 w-2.5" />
</button>
)}
</div>
</div>
);
}

View File

@@ -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<string, string[]>();
// Build dependency info map from bulk edges (includes status for visual indicators)
const depInfoByPhase = useMemo(() => {
const map = new Map<string, PhaseDependencyInfo[]>();
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}

View File

@@ -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({
<h4 className="text-sm font-medium text-muted-foreground">
Dependencies
</h4>
{resolvedDeps.length > 0 && (
<span className="text-xs text-muted-foreground">
{resolvedDeps.filter((d) => d.status === "completed").length}/{resolvedDeps.length} resolved
</span>
)}
{availableDeps.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -330,39 +338,50 @@ export function PhaseDetailPanel({
{resolvedDeps.length === 0 ? (
<p className="text-xs text-muted-foreground">No dependencies</p>
) : (
<div className="space-y-1">
{resolvedDeps.map((dep) => (
<div
key={dep.id}
className="flex items-center gap-2 text-sm"
>
<span
className={
dep.status === "completed"
? "text-status-success-fg"
: "text-muted-foreground"
}
<div className="overflow-hidden rounded-md border border-border">
{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 (
<div
key={dep.id}
className={cn(
"group flex items-center gap-2.5 border-l-[3px] px-3 py-2 text-sm transition-colors hover:bg-accent/30",
borderColor,
idx < resolvedDeps.length - 1 && "border-b border-b-border/50",
)}
>
{dep.status === "completed" ? "\u25CF" : "\u25CB"}
</span>
<span>
Phase {allDisplayIndices.get(dep.id) ?? "?"}: {dep.name}
</span>
<StatusBadge status={dep.status} className="text-[10px]" />
<button
className="ml-1 text-muted-foreground hover:text-destructive"
onClick={() =>
removeDependency.mutate({
phaseId: phase.id,
dependsOnPhaseId: dep.id,
})
}
title="Remove dependency"
>
<X className="h-3 w-3" />
</button>
</div>
))}
<PhaseNumberBadge
index={allDisplayIndices.get(dep.id) ?? 0}
status={dep.status}
/>
<span className="min-w-0 flex-1 truncate">
{dep.name}
</span>
<StatusBadge status={dep.status} className="shrink-0 text-[10px]" />
<button
className="shrink-0 rounded p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
onClick={() =>
removeDependency.mutate({
phaseId: phase.id,
dependsOnPhaseId: dep.id,
})
}
title="Remove dependency (Shift+click to skip confirmation)"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
)}
</div>

View File

@@ -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 (
<button
className={cn(
@@ -73,9 +83,18 @@ export function PhaseSidebarItem({
{renderTaskStatus()}
</div>
{dependencies.length > 0 && (
<div className="text-xs text-muted-foreground">
depends on: {dependencies.join(", ")}
{sortedDeps.length > 0 && (
<div className="flex items-center gap-1 pt-0.5">
<ArrowUp className="h-3 w-3 shrink-0 text-muted-foreground/50" />
<div className="flex flex-wrap gap-0.5">
{sortedDeps.map((dep) => (
<PhaseNumberBadge
key={dep.displayIndex}
index={dep.displayIndex}
status={dep.status}
/>
))}
</div>
</div>
)}
</button>