Skip to content

Commit a72dc7d

Browse files
authored
feat(web-ui): task dependency graph visualization in detail modal (#476)
## Summary - TaskCard dependency icon now has a proper Tooltip: "Depends on N task(s). This task will become READY when all dependencies complete." - TaskDetailModal: full dependency section with task titles, status badges, amber highlight for incomplete deps, and clickable navigation - onOpenTask prop added to TaskDetailModal; TaskBoardView wires it to handleTaskClick - useSWR reuses TaskBoardView's cached task list — zero extra requests - Focus-visible ring on dependency nav buttons for keyboard accessibility ## Validation - Review feedback: 1 Minor fixed (focus-visible ring) - Demo: All 5 ACs verified (tooltip, dep list, amber highlights, navigation, empty state) - CI: All checks green Closes #476
1 parent e1296e7 commit a72dc7d

3 files changed

Lines changed: 63 additions & 12 deletions

File tree

web-ui/src/components/tasks/TaskBoardView.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,7 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) {
376376
onClose={handleCloseDetail}
377377
onExecute={handleExecute}
378378
onStatusChange={handleStatusChange}
379+
onOpenTask={handleTaskClick}
379380
/>
380381

381382
{/* Bulk action confirmation */}

web-ui/src/components/tasks/TaskCard.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Card, CardContent } from '@/components/ui/card';
66
import { Badge } from '@/components/ui/badge';
77
import { Button } from '@/components/ui/button';
88
import { Checkbox } from '@/components/ui/checkbox';
9+
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip';
910
import type { Task, TaskStatus, ProofRequirement } from '@/types';
1011

1112
/** Map backend TaskStatus to badge variant name. */
@@ -97,10 +98,19 @@ export function TaskCard({
9798
</Badge>
9899
</div>
99100
{task.depends_on.length > 0 && (
100-
<span className="flex items-center gap-1 text-xs text-muted-foreground" title={`Depends on ${task.depends_on.length} task(s)`}>
101-
<LinkCircleIcon className="h-3.5 w-3.5" />
102-
{task.depends_on.length}
103-
</span>
101+
<TooltipProvider>
102+
<Tooltip>
103+
<TooltipTrigger asChild>
104+
<span className="flex cursor-default items-center gap-1 text-xs text-muted-foreground">
105+
<LinkCircleIcon className="h-3.5 w-3.5" />
106+
{task.depends_on.length}
107+
</span>
108+
</TooltipTrigger>
109+
<TooltipContent>
110+
Depends on {task.depends_on.length} task{task.depends_on.length !== 1 ? 's' : ''}. This task will become READY when all dependencies complete.
111+
</TooltipContent>
112+
</Tooltip>
113+
</TooltipProvider>
104114
)}
105115
</div>
106116

web-ui/src/components/tasks/TaskDetailModal.tsx

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@ import {
2424
} from '@/components/ui/dialog';
2525
import { Badge } from '@/components/ui/badge';
2626
import { Button } from '@/components/ui/button';
27+
import useSWR from 'swr';
2728
import { tasksApi } from '@/lib/api';
2829
import { useRequirementsLookup } from '@/hooks/useRequirementsLookup';
29-
import type { Task, TaskStatus, ApiError } from '@/types';
30+
import type { Task, TaskStatus, ApiError, TaskListResponse } from '@/types';
3031

3132
const STATUS_BADGE_VARIANT: Record<TaskStatus, string> = {
3233
BACKLOG: 'backlog',
@@ -55,6 +56,7 @@ interface TaskDetailModalProps {
5556
onClose: () => void;
5657
onExecute: (taskId: string) => void;
5758
onStatusChange: () => void;
59+
onOpenTask?: (taskId: string) => void;
5860
}
5961

6062
export function TaskDetailModal({
@@ -64,6 +66,7 @@ export function TaskDetailModal({
6466
onClose,
6567
onExecute,
6668
onStatusChange,
69+
onOpenTask,
6770
}: TaskDetailModalProps) {
6871
const router = useRouter();
6972
const [task, setTask] = useState<Task | null>(null);
@@ -72,6 +75,13 @@ export function TaskDetailModal({
7275
const [isUpdating, setIsUpdating] = useState(false);
7376
const { requirementsMap, isLoading: reqsLoading } = useRequirementsLookup(workspacePath);
7477

78+
// Fetch all tasks (reuses TaskBoardView's SWR cache — same key, no extra network call)
79+
const { data: allTasksData } = useSWR<TaskListResponse>(
80+
`/api/v2/tasks?path=${workspacePath}`,
81+
() => tasksApi.getAll(workspacePath)
82+
);
83+
const tasksById = new Map(allTasksData?.tasks.map((t) => [t.id, t]) ?? []);
84+
7585
useEffect(() => {
7686
if (!open || !taskId) {
7787
setTask(null);
@@ -172,13 +182,6 @@ export function TaskDetailModal({
172182

173183
{/* Metadata */}
174184
<div className="flex flex-wrap gap-4 text-xs text-muted-foreground">
175-
{task.depends_on.length > 0 && (
176-
<span className="flex items-center gap-1">
177-
<LinkCircleIcon className="h-3.5 w-3.5" />
178-
{task.depends_on.length} dependenc{task.depends_on.length === 1 ? 'y' : 'ies'}:
179-
{' '}{task.depends_on.map((id) => id.slice(0, 8)).join(', ')}
180-
</span>
181-
)}
182185
{task.estimated_hours != null && (
183186
<span className="flex items-center gap-1">
184187
<Time01Icon className="h-3.5 w-3.5" />
@@ -187,6 +190,43 @@ export function TaskDetailModal({
187190
)}
188191
</div>
189192

193+
{/* Dependencies — full list with status highlights and navigation */}
194+
{task.depends_on.length > 0 && (
195+
<div className="space-y-1.5">
196+
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
197+
<LinkCircleIcon className="h-3.5 w-3.5" />
198+
Dependencies ({task.depends_on.length})
199+
</div>
200+
<ul className="space-y-1">
201+
{task.depends_on.map((depId) => {
202+
const dep = tasksById.get(depId);
203+
const isIncomplete = dep && !['DONE', 'MERGED'].includes(dep.status);
204+
return (
205+
<li key={depId} className="flex items-center gap-2 text-xs">
206+
{dep && (
207+
<Badge variant={STATUS_BADGE_VARIANT[dep.status] as never} className="shrink-0 text-[10px]">
208+
{dep.status}
209+
</Badge>
210+
)}
211+
{onOpenTask ? (
212+
<button
213+
className={`truncate text-left hover:underline focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring ${isIncomplete ? 'font-medium text-amber-700 dark:text-amber-400' : 'text-foreground'}`}
214+
onClick={() => { onClose(); onOpenTask(depId); }}
215+
>
216+
{dep?.title ?? depId.slice(0, 12)}
217+
</button>
218+
) : (
219+
<span className={`truncate ${isIncomplete ? 'font-medium text-amber-700 dark:text-amber-400' : 'text-foreground'}`}>
220+
{dep?.title ?? depId.slice(0, 12)}
221+
</span>
222+
)}
223+
</li>
224+
);
225+
})}
226+
</ul>
227+
</div>
228+
)}
229+
190230
{/* Requirements */}
191231
{(task.requirement_ids ?? []).length > 0 && (
192232
<div className="space-y-1.5">

0 commit comments

Comments
 (0)