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:
97
apps/web/src/components/DependencyChip.tsx
Normal file
97
apps/web/src/components/DependencyChip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 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="flex items-center gap-2 text-sm"
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
dep.status === "completed"
|
||||
? "text-status-success-fg"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{dep.status === "completed" ? "\u25CF" : "\u25CB"}
|
||||
<PhaseNumberBadge
|
||||
index={allDisplayIndices.get(dep.id) ?? 0}
|
||||
status={dep.status}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{dep.name}
|
||||
</span>
|
||||
<span>
|
||||
Phase {allDisplayIndices.get(dep.id) ?? "?"}: {dep.name}
|
||||
</span>
|
||||
<StatusBadge status={dep.status} className="text-[10px]" />
|
||||
<StatusBadge status={dep.status} className="shrink-0 text-[10px]" />
|
||||
<button
|
||||
className="ml-1 text-muted-foreground hover:text-destructive"
|
||||
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"
|
||||
title="Remove dependency (Shift+click to skip confirmation)"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user