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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface DependencyItem {
|
interface DependencyItem {
|
||||||
@@ -11,18 +14,97 @@ interface DependencyIndicatorProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_VISIBLE = 3;
|
||||||
|
|
||||||
export function DependencyIndicator({
|
export function DependencyIndicator({
|
||||||
blockedBy,
|
blockedBy,
|
||||||
type: _type,
|
type: _type,
|
||||||
className,
|
className,
|
||||||
}: DependencyIndicatorProps) {
|
}: DependencyIndicatorProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
if (blockedBy.length === 0) return null;
|
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 (
|
return (
|
||||||
<div className={cn("pl-8 text-sm text-status-warning-fg", className)}>
|
<div
|
||||||
<span className="font-mono">^</span> blocked by: {names}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
TaskModal,
|
TaskModal,
|
||||||
type PhaseData,
|
type PhaseData,
|
||||||
} from "@/components/execution";
|
} from "@/components/execution";
|
||||||
import { PhaseSidebarItem } from "@/components/execution/PhaseSidebarItem";
|
import { PhaseSidebarItem, type PhaseDependencyInfo } from "@/components/execution/PhaseSidebarItem";
|
||||||
import {
|
import {
|
||||||
PhaseDetailPanel,
|
PhaseDetailPanel,
|
||||||
PhaseDetailEmpty,
|
PhaseDetailEmpty,
|
||||||
@@ -39,15 +39,17 @@ export function ExecutionTab({
|
|||||||
[phases, dependencyEdges],
|
[phases, dependencyEdges],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build dependency name map from bulk edges
|
// Build dependency info map from bulk edges (includes status for visual indicators)
|
||||||
const depNamesByPhase = useMemo(() => {
|
const depInfoByPhase = useMemo(() => {
|
||||||
const map = new Map<string, string[]>();
|
const map = new Map<string, PhaseDependencyInfo[]>();
|
||||||
const phaseIndex = new Map(sortedPhases.map((p, i) => [p.id, i + 1]));
|
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) {
|
for (const edge of dependencyEdges) {
|
||||||
const depIdx = phaseIndex.get(edge.dependsOnPhaseId);
|
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) ?? [];
|
const existing = map.get(edge.phaseId) ?? [];
|
||||||
existing.push(`Phase ${depIdx}`);
|
existing.push({ displayIndex: depIdx, status: depStatus });
|
||||||
map.set(edge.phaseId, existing);
|
map.set(edge.phaseId, existing);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
@@ -235,7 +237,7 @@ export function ExecutionTab({
|
|||||||
taskCount={
|
taskCount={
|
||||||
taskCountsByPhase[phase.id] ?? { complete: 0, total: 0 }
|
taskCountsByPhase[phase.id] ?? { complete: 0, total: 0 }
|
||||||
}
|
}
|
||||||
dependencies={depNamesByPhase.get(phase.id) ?? []}
|
dependencies={depInfoByPhase.get(phase.id) ?? []}
|
||||||
isSelected={phase.id === activePhaseId}
|
isSelected={phase.id === activePhaseId}
|
||||||
onClick={() => setSelectedPhaseId(phase.id)}
|
onClick={() => setSelectedPhaseId(phase.id)}
|
||||||
detailAgent={detailAgentByPhase.get(phase.id) ?? null}
|
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 { toast } from "sonner";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
|
import { mapEntityStatus } from "@/components/StatusDot";
|
||||||
|
import { PhaseNumberBadge } from "@/components/DependencyChip";
|
||||||
import { TaskRow, type SerializedTask } from "@/components/TaskRow";
|
import { TaskRow, type SerializedTask } from "@/components/TaskRow";
|
||||||
import { PhaseContentEditor } from "@/components/editor/PhaseContentEditor";
|
import { PhaseContentEditor } from "@/components/editor/PhaseContentEditor";
|
||||||
import { ChangeSetBanner } from "@/components/ChangeSetBanner";
|
import { ChangeSetBanner } from "@/components/ChangeSetBanner";
|
||||||
@@ -16,6 +18,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { sortByPriorityAndQueueTime } from "@codewalk-district/shared";
|
import { sortByPriorityAndQueueTime } from "@codewalk-district/shared";
|
||||||
import { useExecutionContext, type FlatTaskEntry } from "./ExecutionContext";
|
import { useExecutionContext, type FlatTaskEntry } from "./ExecutionContext";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface PhaseDetailPanelProps {
|
interface PhaseDetailPanelProps {
|
||||||
phase: {
|
phase: {
|
||||||
@@ -302,6 +305,11 @@ export function PhaseDetailPanel({
|
|||||||
<h4 className="text-sm font-medium text-muted-foreground">
|
<h4 className="text-sm font-medium text-muted-foreground">
|
||||||
Dependencies
|
Dependencies
|
||||||
</h4>
|
</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 && (
|
{availableDeps.length > 0 && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -330,39 +338,50 @@ export function PhaseDetailPanel({
|
|||||||
{resolvedDeps.length === 0 ? (
|
{resolvedDeps.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">No dependencies</p>
|
<p className="text-xs text-muted-foreground">No dependencies</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="overflow-hidden rounded-md border border-border">
|
||||||
{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 (
|
||||||
<div
|
<div
|
||||||
key={dep.id}
|
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
|
<PhaseNumberBadge
|
||||||
className={
|
index={allDisplayIndices.get(dep.id) ?? 0}
|
||||||
dep.status === "completed"
|
status={dep.status}
|
||||||
? "text-status-success-fg"
|
/>
|
||||||
: "text-muted-foreground"
|
<span className="min-w-0 flex-1 truncate">
|
||||||
}
|
{dep.name}
|
||||||
>
|
|
||||||
{dep.status === "completed" ? "\u25CF" : "\u25CB"}
|
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<StatusBadge status={dep.status} className="shrink-0 text-[10px]" />
|
||||||
Phase {allDisplayIndices.get(dep.id) ?? "?"}: {dep.name}
|
|
||||||
</span>
|
|
||||||
<StatusBadge status={dep.status} className="text-[10px]" />
|
|
||||||
<button
|
<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={() =>
|
onClick={() =>
|
||||||
removeDependency.mutate({
|
removeDependency.mutate({
|
||||||
phaseId: phase.id,
|
phaseId: phase.id,
|
||||||
dependsOnPhaseId: dep.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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { Loader2 } from "lucide-react";
|
import { ArrowUp, Loader2 } from "lucide-react";
|
||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
|
import { PhaseNumberBadge } from "@/components/DependencyChip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface PhaseDependencyInfo {
|
||||||
|
displayIndex: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface PhaseSidebarItemProps {
|
interface PhaseSidebarItemProps {
|
||||||
phase: {
|
phase: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -10,7 +16,7 @@ interface PhaseSidebarItemProps {
|
|||||||
};
|
};
|
||||||
displayIndex: number;
|
displayIndex: number;
|
||||||
taskCount: { complete: number; total: number };
|
taskCount: { complete: number; total: number };
|
||||||
dependencies: string[];
|
dependencies: PhaseDependencyInfo[];
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
detailAgent?: { status: string } | null;
|
detailAgent?: { status: string } | null;
|
||||||
@@ -52,6 +58,10 @@ export function PhaseSidebarItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sortedDeps = [...dependencies].sort(
|
||||||
|
(a, b) => a.displayIndex - b.displayIndex,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -73,9 +83,18 @@ export function PhaseSidebarItem({
|
|||||||
{renderTaskStatus()}
|
{renderTaskStatus()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dependencies.length > 0 && (
|
{sortedDeps.length > 0 && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="flex items-center gap-1 pt-0.5">
|
||||||
depends on: {dependencies.join(", ")}
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user