Files
PaperClipAI/ui/src/components/task-detail/TaskDetailRelationsPanel.tsx
T
DottaandPaperclip b315580640 feat: add private task creation and sharing controls (#14718)
Add private task creation and sharing controls, effective access explanations, private project management, and locked task references.

Constrain the mobile composer plus menu to the viewport and explain named inherited privacy on hover, focus, or tap. Include 125 production-component Storybook stories and thirteen interactive user journeys.

Final head passes all CI checks and Greptile Apex 5/5 with no findings.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-07 07:27:52 -05:00

246 lines
7.9 KiB
TypeScript

import { isLockedIssueStub, LockedIssueChip } from "@/components/LockedIssueChip";
import type { Issue, IssueStatus, IssueLockedStub } from "@paperclipai/shared";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { IssueRow } from "@/components/IssueRow";
import { StatusIcon } from "@/components/StatusIcon";
import {
createIssueDetailPath,
rememberIssueDetailLocationState,
} from "@/lib/issueDetailBreadcrumb";
import { Link } from "@/lib/router";
export interface TaskDetailRelationItem {
id: string;
identifier?: string | null;
title: string;
status?: string | null;
}
export function RelationNavigationList({
items,
emptyMessage,
ariaLabel,
issueLinkState,
}: {
items: (TaskDetailRelationItem | IssueLockedStub)[];
emptyMessage: string;
ariaLabel: string;
issueLinkState?: unknown;
}) {
if (items.length === 0) {
return <p className="py-6 text-center text-sm text-muted-foreground">{emptyMessage}</p>;
}
return (
<ul className="flex flex-col gap-0.5" aria-label={ariaLabel}>
{items.map((item) => {
if (isLockedIssueStub(item)) {
return <li key={item.id} className="px-2 py-2"><LockedIssueChip identifier={item.identifier} /></li>;
}
const pathId = item.identifier ?? item.id;
return (
<li key={item.id}>
<Link
to={createIssueDetailPath(pathId)}
state={issueLinkState}
onClickCapture={() => rememberIssueDetailLocationState(pathId, issueLinkState)}
className="flex min-w-0 items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={`${item.identifier ? `${item.identifier} — ` : ""}${item.title}`}
>
{item.status ? (
<StatusIcon status={item.status} className="h-3.5 w-3.5 shrink-0" />
) : null}
<span className="min-w-0 flex-1 truncate">{item.title}</span>
{item.identifier ? (
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{item.identifier}
</span>
) : null}
</Link>
</li>
);
})}
</ul>
);
}
const NEXT_SUBTASK_STATUS_ORDER: IssueStatus[] = [
"in_progress",
"in_review",
"todo",
"backlog",
"blocked",
];
export function resolveTaskDetailSubtaskState(items: Issue[]) {
const nextAction = NEXT_SUBTASK_STATUS_ORDER
.map((status) => items.find((item) => item.status === status))
.find((item): item is Issue => Boolean(item)) ?? null;
const rootBlocker = nextAction?.status === "blocked"
? nextAction.blockerAttention?.terminalBlocker ?? null
: null;
return {
nextAction,
rootBlocker,
remainingItems: nextAction
? items.filter((item) => item.id !== nextAction.id)
: items,
};
}
export function TaskDetailTaskList({
items,
ariaLabel,
issueLinkState,
}: {
items: Issue[];
ariaLabel: string;
issueLinkState?: unknown;
}) {
return (
<ul className="flex flex-col gap-0.5" aria-label={ariaLabel}>
{items.map((item) => (
<li key={item.id}>
<IssueRow
issue={item}
issueLinkState={issueLinkState}
presentation="task"
className="rounded-md"
/>
</li>
))}
</ul>
);
}
export function TaskDetailSubtasksPanel({
items,
onAddSubtask,
issueLinkState,
}: {
items: Issue[];
onAddSubtask?: () => void;
issueLinkState?: unknown;
}) {
const completed = items.filter((item) => item.status === "done").length;
const progress = items.length > 0 ? Math.round((completed / items.length) * 100) : 0;
const { nextAction, rootBlocker, remainingItems } = resolveTaskDetailSubtaskState(items);
const allCompleted = items.length > 0 && completed === items.length;
return (
<section className="flex flex-col gap-4" aria-label="Subtasks">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>Progress</span>
<span className="font-mono">{completed} of {items.length} complete</span>
</div>
<div
className="h-1.5 overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-label="Subtask completion"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
>
<div
className="h-full rounded-full bg-(--status-task-icon-done) transition-[width]"
style={{ width: `${progress}%` }}
/>
</div>
</div>
{rootBlocker ? (
<section className="flex flex-col gap-1.5" aria-labelledby="task-root-blocker-heading">
<h3 id="task-root-blocker-heading" className="text-xs font-medium text-muted-foreground">
Root blocker
</h3>
<RelationNavigationList
items={[rootBlocker]}
emptyMessage=""
ariaLabel="Root blocker"
issueLinkState={issueLinkState}
/>
</section>
) : null}
{nextAction ? (
<section className="flex flex-col gap-1.5" aria-labelledby="task-next-action-heading">
<h3 id="task-next-action-heading" className="text-xs font-medium text-muted-foreground">
{nextAction.status === "blocked" ? "Blocked subtask" : "Next action"}
</h3>
<TaskDetailTaskList
items={[nextAction]}
ariaLabel="Next subtask action"
issueLinkState={issueLinkState}
/>
</section>
) : items.length > 0 ? (
<p className="text-xs text-muted-foreground">
{allCompleted ? "All subtasks are complete." : "No remaining subtask actions."}
</p>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">No subtasks yet.</p>
)}
{remainingItems.length > 0 ? (
<section className="flex flex-col gap-1.5" aria-labelledby="task-other-subtasks-heading">
<h3 id="task-other-subtasks-heading" className="text-xs font-medium text-muted-foreground">
{nextAction ? "Other subtasks" : "Subtasks"}
</h3>
<TaskDetailTaskList
items={remainingItems}
ariaLabel={nextAction ? "Other subtasks" : "Subtasks"}
issueLinkState={issueLinkState}
/>
</section>
) : null}
{onAddSubtask ? (
<Button type="button" variant="outline" size="sm" className="self-start" onClick={onAddSubtask}>
<Plus className="h-3.5 w-3.5" />
Add subtask
</Button>
) : null}
</section>
);
}
export function TaskDetailReferencesPanel({
referenced,
mentionedIn,
issueLinkState,
}: {
referenced: TaskDetailRelationItem[];
mentionedIn: TaskDetailRelationItem[];
issueLinkState?: unknown;
}) {
return (
<div className="flex flex-col gap-6">
<section className="flex flex-col gap-2" aria-labelledby="task-referenced-heading">
<h3 id="task-referenced-heading" className="text-xs font-medium text-muted-foreground">
Referenced
</h3>
<RelationNavigationList
items={referenced}
emptyMessage="This task does not reference another task."
ariaLabel="Referenced tasks"
issueLinkState={issueLinkState}
/>
</section>
<section className="flex flex-col gap-2" aria-labelledby="task-mentioned-in-heading">
<h3 id="task-mentioned-in-heading" className="text-xs font-medium text-muted-foreground">
Mentioned in
</h3>
<RelationNavigationList
items={mentionedIn}
emptyMessage="No other task mentions this task."
ariaLabel="Tasks that mention this task"
issueLinkState={issueLinkState}
/>
</section>
</div>
);
}