Files
PaperClipAI/ui/src/components/task-detail/TaskDetailRelationsPanel.tsx
T
DottaandPaperclip 2788f20fc0 fix(ui): show ancestors in the task detail Tasks panel (#13823)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Tasks form a hierarchy that explains why each piece of work exists.
> - The streamlined Tasks panel shows child tasks and work created by
the current task.
> - It does not show ancestors, although the task response already
includes them.
> - This pull request adds linked ancestors above Subtasks in
root-to-parent order.
> - Users can now move up the task hierarchy from the same panel.

## Linked Issues or Issue Description

Related implementation: #13241. A search found no duplicate
ancestor-panel PR.

**What happened?**

The Tasks panel omitted the current task's ancestors. The streamlined
header also hides hierarchy breadcrumbs.

**Expected behavior**

The Tasks panel should show the ancestor chain and let users open each
ancestor.

**Steps to reproduce**

1. Open a task with a parent and grandparent in the streamlined UI.
2. Open the Tasks tab in the side panel.
3. Observe that it shows child and created tasks but no ancestors.

**Paperclip version or commit**

Reproduced on master at 3d78e3a4e. Rebased onto 5f1100e3b before
submission.

**Deployment mode**

Built from source. Verified with local Storybook fixtures that render
the production task page.

## What Changed

- Pass ancestors from the task response to the Tasks panel. Include them
in its task count.
- Show a collapsible Ancestors section above Subtasks. Reuse the
existing relation links and preserve navigation state.
- Keep the panel available for tasks that have ancestors but no children
or created work.
- Add tests and full-page, narrow, light, and ancestor-only Storybook
cases. Document the new cases.

## Verification

- Passed 129 targeted tests across TaskDetailTasksPanel,
TaskDetailRelationsPanel, and IssueDetail.
- Passed UI typecheck, UI build, Storybook build, and token gates before
the rebase.
- Opened the full production-page Storybook in the embedded browser.
Confirmed root-to-parent order and navigation to the parent task. The
user reviewed this preview before requesting the PR.
- Passed full local `pnpm build` and `pnpm -r typecheck` on the rebased
commit.
- All remote checks passed on
`f54eebe5e0ba1d5c801545bc8edeaa875eee7682`: 53 successful checks, two
intentional Storybook skips, and a passing Snyk status. This includes
the full test matrix and browser E2E tests.
- Started local `pnpm test:run`, then stopped the duplicate run after
the full CI test matrix passed. The complete local test run is not
claimed as a pass.
- Greptile returned 5/5 with no actionable findings or inline review
comments.
- Manual review: open Storybook → UX Labs → Tasks Created From a Task →
With Ancestors. Check the ancestor links, independent collapse, and
Ancestors Only state.

## Risks

- Low risk. This adds UI for an existing company-scoped task response.
It adds no API, database, or network request.
- The display relies on the existing API order, which lists the
immediate parent first. A regression test verifies the reversed display
order.
- Tasks with no ancestors retain the existing empty and subtask states.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, code editing, shell
execution, and browser tools. The exact serving model ID and
context-window size are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-22 13:47:10 -05:00

242 lines
7.6 KiB
TypeScript

import type { Issue, IssueStatus } 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[];
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) => {
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>
);
}