"use client"; /** * TaskDependencyChip — the status-row chip that shows what a task is waiting on * and what is waiting on it. * * Mounted next to `` in the row above the composer. Both * directions live on the task payload already (`dependsOn` / `blocks`, each * entry carrying id/title/state), so opening the chip costs no fetch. * * Follows PRStatusChip's two conventions: return null when the task has no * edges, and use a drawer on touch rather than a hover card. */ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { IconLink, IconLock, IconAlertTriangle, IconCheck, IconClock } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, } from "@kandev/ui/drawer"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { cn } from "@kandev/ui/lib/utils"; import { useAppStore } from "@/components/state-provider"; import TaskLink from "@/components/routing/task-link"; import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; import { getTaskDependencies } from "@/lib/api/domains/task-dependencies-api"; import type { TaskDependencyRef } from "@/lib/state/slices/kanban/types"; type DependencyChipData = { blocked: boolean; blockedReason?: string; dependsOn: TaskDependencyRef[]; blocks: TaskDependencyRef[]; }; /** * useTaskDependencies reads the derived dependency projection for a task from * the store. Returns null when the task is unknown or has no edges in either * direction, which is also the chip's "render nothing" signal. * * The selector returns the task itself — a stable reference — and the derived * shape is built in a memo afterwards. Returning a freshly-built object FROM the * selector compares unequal under Object.is on every store read, which loops * until React bails out and renders nothing at all. */ export function useTaskDependencies(taskId: string | null): DependencyChipData | null { const fromStore = useTaskDependenciesFromStore(taskId); const storeAnswered = fromStore !== undefined; const fetched = useFetchedTaskDependencies(taskId, storeAnswered); // `??` would be wrong here: the store's `null` is a real answer ("this task // has no edges"), and falling through on it would keep showing the previously // fetched task's dependencies after an edge is removed or after switching to // a task the store knows is edge-free. Only `undefined` means "ask the server". return storeAnswered ? fromStore : fetched; } /** * Fetches the projection when the store copy has none. * * `undefined` from the store means "this copy carries no dependency fields" (a * lightweight task.updated overwrote them, or landed before hydration); `null` * means "read it, there are no edges". Only the former needs a fetch. */ function useFetchedTaskDependencies( taskId: string | null, storeAnswered: boolean, ): DependencyChipData | null { const [data, setData] = useState(null); useEffect(() => { if (!taskId || storeAnswered) { // Drop anything fetched for a previous task/state so it can never be // rendered again once the store can answer. setData(null); return; } let cancelled = false; void getTaskDependencies(taskId) .then((task) => { if (cancelled) return; const dependsOn = (task.depends_on ?? []) as TaskDependencyRef[]; const blocks = (task.blocks ?? []) as TaskDependencyRef[]; setData( dependsOn.length === 0 && blocks.length === 0 ? null : { blocked: task.blocked ?? false, blockedReason: task.blocked_reason, dependsOn, blocks, }, ); }) .catch(() => { // A failed read hides the chip rather than showing a wrong state. if (!cancelled) setData(null); }); return () => { cancelled = true; }; }, [taskId, storeAnswered]); return data; } /** * Store fast path. Returns `undefined` when the store copy carries no dependency * projection at all, so the caller knows to fetch rather than render nothing. */ function useTaskDependenciesFromStore( taskId: string | null, ): DependencyChipData | null | undefined { const task = useAppStore((state) => { if (!taskId) return undefined; // Nil-safe on both slices: this renders inside a task panel that can mount // before board hydration completes. const fromBoard = state.kanban?.tasks?.find((candidate) => candidate.id === taskId); if (fromBoard) return fromBoard; for (const snapshot of Object.values(state.kanbanMulti?.snapshots ?? {})) { const hit = snapshot.tasks?.find((candidate) => candidate.id === taskId); if (hit) return hit; } return undefined; }); return useMemo(() => { if (!task) return undefined; // No projection on this copy: the caller must ask the server. if (task.dependsOn === undefined && task.blocks === undefined) return undefined; const dependsOn = task.dependsOn ?? []; const blocks = task.blocks ?? []; if (dependsOn.length === 0 && blocks.length === 0) return null; return { blocked: task.blocked ?? false, blockedReason: task.blockedReason, dependsOn, blocks, }; }, [task]); } function statusIcon(status?: string) { if (status === "resolved") return ; if (status === "failed") return ; return ; } function DependencyRow({ ref: entry, showStatus, touchTarget, onNavigated, }: { ref: TaskDependencyRef; showStatus: boolean; touchTarget: boolean; onNavigated: () => void; }) { const { t } = useTranslation(); return ( {showStatus ? ( statusIcon(entry.status) ) : ( )} {entry.title || entry.id} {entry.state && ( {t(`task:state.${entry.state}`, { defaultValue: entry.state })} )} ); } function DependencyLists({ data, touchTarget, onNavigated, }: { data: DependencyChipData; touchTarget: boolean; onNavigated: () => void; }) { const { t } = useTranslation(); return (
{data.dependsOn.length > 0 && (

{t("task:dependencyBlockedBy")}

{data.dependsOn.map((entry) => ( ))}
)} {data.blocks.length > 0 && (

{t("task:dependencyBlocks")}

{data.blocks.map((entry) => ( ))}
)} {data.blockedReason === "failed" && (

{t("task:dependencyChainHalted")}

)}
); } function ChipTriggerContent({ data }: { data: DependencyChipData }) { const { t } = useTranslation(); return ( <> {data.blocked ? : } {data.dependsOn.length > 0 && t("task:dependencyCountBlockedBy", { count: data.dependsOn.length })} {data.dependsOn.length > 0 && data.blocks.length > 0 && " · "} {data.blocks.length > 0 && t("task:dependencyCountBlocks", { count: data.blocks.length })} ); } /** * Pill styling follows the house formula for status chips: a fully rounded * outline, a 10%-opacity tint of the state colour, a 35%-opacity border of the * same colour, and the colour itself as the text — see the workspace badge in * `app-sidebar/sections/settings/workspaces-group.tsx`. A solid fill with muted * text reads as a disabled button next to the sibling chips in this row. * * Reds use the repo's soft pair `text-red-600 dark:text-red-400` (see * `settings/plugins/plugin-status-badge.tsx`): a flat red-500 is harsh against a * dark surface. * * Colour carries the state: red when the chain has halted on a failed * predecessor (needs a human), primary while merely waiting or purely * informational. Deliberately not amber — that is the Autopilot chip's colour and * both can sit in this row at once. */ const TRIGGER_BASE = "inline-flex h-[26px] shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-2.5 text-xs font-medium leading-none transition-colors"; const TRIGGER_TONES = { failed: "border-red-500/40 bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/15", waiting: "border-primary/35 bg-primary/10 text-primary hover:bg-primary/15", } as const; function triggerTone(data: DependencyChipData): string { return data.blockedReason === "failed" ? TRIGGER_TONES.failed : TRIGGER_TONES.waiting; } export function TaskDependencyChip({ taskId }: { taskId: string | null }) { const data = useTaskDependencies(taskId); const usesMobileDrawer = useTouchDrawer(); const { t } = useTranslation(); const [open, setOpen] = useState(false); // No edges in either direction: nothing to say. Same null-when-not-applicable // contract as PRStatusChip. if (!data) return null; const toneClass = triggerTone(data); if (usesMobileDrawer) { return ( {t("task:dependencies")} {t("task:dependencyChipLabel")}
setOpen(false)} />
); } return ( setOpen(false)} /> ); } /** Badge form used where a chip trigger is not appropriate (e.g. compact rows). */ export function TaskDependencyBadge({ taskId }: { taskId: string | null }) { const data = useTaskDependencies(taskId); const { t } = useTranslation(); if (!data?.blocked) return null; return ( {t("task:dependencyCountBlockedBy", { count: data.dependsOn.length })} ); }