import type { IssueBlockerAttentionIssueSummary, IssueRelationIssueSummary, } from "@paperclipai/shared"; import { useMemo } from "react"; import { useQueries } from "@tanstack/react-query"; import { ISSUE_SUMMARIES_MAX_IDS } from "@paperclipai/shared"; import { issuesApi } from "@/api/issues"; import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled"; import { createIssueDetailPath } from "@/lib/issueDetailBreadcrumb"; import { queryKeys } from "@/lib/queryKeys"; import { taskStatusIconProps, type TaskStatusPresentationInput } from "@/lib/task-status-presentation"; import { StatusIcon } from "../StatusIcon"; import { orderWaitingBlockers, type WaitingBlockerStep, } from "@/lib/issue-blockers"; import { Link } from "@/lib/router"; import { useStreamlinedTaskChatPresentation } from "./presentation-mode"; function isUnresolved(blocker: IssueRelationIssueSummary): boolean { return blocker.status !== "done" && blocker.status !== "cancelled"; } export function resolveTaskChatBlockers( blockers: IssueRelationIssueSummary[], terminalBlockerIssueId?: string | null, directBlockerIssueId?: string | null, terminalBlocker?: IssueBlockerAttentionIssueSummary | null, ): { directBlocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary; ultimateBlocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary | null; } | null { const unresolvedBlockers = blockers.filter(isUnresolved); if (unresolvedBlockers.length === 0) return null; const directBlocker = directBlockerIssueId ? unresolvedBlockers.find((blocker) => blocker.id === directBlockerIssueId) : terminalBlockerIssueId ? unresolvedBlockers.find((blocker) => ( blocker.id === terminalBlockerIssueId || blocker.terminalBlockers?.some((terminal) => terminal.id === terminalBlockerIssueId) )) : unresolvedBlockers[0]; // A selected intermediate blocker is not part of `terminalBlockers`, which // intentionally contains only structural leaves. If its direct path is not // in this payload (for example a child-derived attention path), show the // selected task itself instead of falling back to an unrelated blocker. if (!directBlocker) { if (!terminalBlocker) return null; return { directBlocker: terminalBlocker, ultimateBlocker: null, }; } const terminalBlockers = directBlocker.terminalBlockers?.filter(isUnresolved) ?? []; const ultimateBlocker = terminalBlockerIssueId ? terminalBlocker?.id === terminalBlockerIssueId ? terminalBlocker : terminalBlockers.find((blocker) => blocker.id === terminalBlockerIssueId) ?? null : terminalBlockers[0] ?? null; return { directBlocker, ultimateBlocker: ultimateBlocker?.id === directBlocker.id ? null : ultimateBlocker, }; } export interface ResolvedTaskChatLiveWork { steps: WaitingBlockerStep[]; nowRunning: Array; } export function resolveTaskChatLiveWork( blockers: IssueRelationIssueSummary[], liveIssueIds: ReadonlySet, selectedTerminalBlocker?: IssueBlockerAttentionIssueSummary | null, ): ResolvedTaskChatLiveWork | null { if (blockers.length === 0) return null; const steps = orderWaitingBlockers(blockers, liveIssueIds); const stepIds = new Set(steps.map((step) => step.blocker.id)); const terminalCandidates: Array = []; for (const blocker of blockers) terminalCandidates.push(...(blocker.terminalBlockers ?? [])); if (selectedTerminalBlocker) terminalCandidates.push(selectedTerminalBlocker); const nowRunning: Array = []; const seen = new Set(); for (const blocker of terminalCandidates) { if (!liveIssueIds.has(blocker.id) || stepIds.has(blocker.id) || seen.has(blocker.id)) continue; seen.add(blocker.id); nowRunning.push(blocker); } const hasLiveStep = steps.some((step) => step.status === "running"); if (!hasLiveStep && nowRunning.length === 0) return null; return { steps, nowRunning }; } function BlockerRow({ label, blocker, }: { label: string; blocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary; }) { const streamlined = useStreamlinedTaskChatPresentation(); const issuePathId = blocker.identifier ?? blocker.id; return streamlined ? ( {label} {blocker.identifier ?? blocker.id.slice(0, 8)} {blocker.title} ) : (
{label} {blocker.identifier ?? blocker.id.slice(0, 8)} {blocker.title}
); } type LiveWorkBlocker = IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary; /** * The strip shows tasks, so it draws the shared task status glyph. Relation * summaries do not carry blocker attention or the waiting-conversation state, * so the same `/issues/summaries` projection that the sidebar and created-task * cards use supplies them; until it loads, the relation status is used. */ function useLiveWorkStatusPresentation( companyId: string | null | undefined, blockers: LiveWorkBlocker[], ): (blocker: LiveWorkBlocker) => TaskStatusPresentationInput { const { userId } = useCoordinatorWorkspaceEnabled(); // The summaries endpoint accepts at most ISSUE_SUMMARIES_MAX_IDS per request. const batches = useMemo(() => { const ids = [...new Set(blockers.map((blocker) => blocker.id))].sort(); const chunks: string[][] = []; for (let index = 0; index < ids.length; index += ISSUE_SUMMARIES_MAX_IDS) { chunks.push(ids.slice(index, index + ISSUE_SUMMARIES_MAX_IDS)); } return chunks; }, [blockers]); const results = useQueries({ queries: batches.map((ids) => ({ queryKey: queryKeys.issues.summaryBatch(companyId ?? "__none__", userId, ids), queryFn: ({ signal }: { signal: AbortSignal }) => issuesApi.summaries(companyId!, ids, { signal }), enabled: Boolean(companyId), })), }); const summaries = results.flatMap((result) => result.data ?? []); return (blocker) => { const summary = summaries.find((candidate) => candidate.id === blocker.id); if (summary) return taskStatusIconProps(summary); if ("status" in blocker) return taskStatusIconProps(blocker); // A sampled terminal blocker carries no status; it is only listed while live. return { status: "in_progress" }; }; } function LiveWorkGlyph({ presentation }: { presentation: TaskStatusPresentationInput }) { return ; } function LiveWorkLink({ blocker, presentation, label, }: { blocker: LiveWorkBlocker; presentation: TaskStatusPresentationInput; label?: string; }) { const streamlined = useStreamlinedTaskChatPresentation(); const issuePathId = blocker.identifier ?? blocker.id; return ( {streamlined && label ? {label} : null} {streamlined ? : null} {blocker.identifier ?? blocker.id.slice(0, 8)} {blocker.title} ); } export function TaskChatBlockerLinks({ directBlocker, ultimateBlocker, placement, }: { directBlocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary; ultimateBlocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary | null; placement: "top" | "bottom"; }) { const streamlined = useStreamlinedTaskChatPresentation(); const directLabel = streamlined && placement === "bottom" ? "Still blocked by" : "Blocked by"; const rootLabel = streamlined ? placement === "bottom" ? "Root blocker remains" : "Root blocker" : "Ultimately blocked by"; return (
{ultimateBlocker ? ( ) : null}
); } export function TaskChatLiveWorkLinks({ liveWork, placement, companyId, }: { liveWork: ResolvedTaskChatLiveWork; placement: "top" | "bottom"; companyId?: string | null; }) { const streamlined = useStreamlinedTaskChatPresentation(); const listedBlockers = useMemo( () => [...liveWork.steps.map((step) => step.blocker), ...liveWork.nowRunning], [liveWork], ); const presentationFor = useLiveWorkStatusPresentation(companyId, listedBlockers); const heading = streamlined && placement === "bottom" ? "Still waiting on live work" : "Waiting on live work"; return (
{heading}
    {liveWork.steps.map(({ blocker }, index) => (
  1. {!streamlined ? ( <> {index + 1}. ) : null}
  2. ))}
{liveWork.nowRunning.map((blocker) => streamlined ? ( ) : (
Now running
))}
); }