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>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-22 13:47:10 -05:00
1 parent 5f1100e3b3
commit 2788f20fc0
8 files changed
+100 -18

No files matched your search

@@ -13,10 +13,10 @@ export interface TaskDetailRelationItem {
id: string;
identifier?: string | null;
title: string;
status?: IssueStatus | null;
status?: string | null;
}
function RelationNavigationList({
export function RelationNavigationList({
items,
emptyMessage,
ariaLabel,
@@ -19,7 +19,37 @@ function render(props: React.ComponentProps<typeof TaskDetailTasksPanel>) {
act(() => root.render(<TaskDetailTasksPanel {...props} />));
}
const ancestors = [
{ id: "parent", identifier: "PAP-2", title: "Immediate parent", status: "in_progress" },
{ id: "root", identifier: null, title: "Root task", status: "done" },
] as NonNullable<Issue["ancestors"]>;
describe("TaskDetailTasksPanel", () => {
it("shows ancestors root first above subtasks and folds them independently", () => {
render({ ancestors, subtasks: [task("child")], createdTasks: [], projects: [] });
const group = container.querySelector('section[aria-label="Ancestors"]')!;
expect([...group.querySelectorAll('a')].map((link) => link.getAttribute("href"))).toEqual(["/issues/root", "/issues/PAP-2"]);
expect(group.textContent).toContain("Immediate parent");
expect(group.querySelector('[role="progressbar"]')).toBeNull();
expect([...container.querySelectorAll('h2')].map((heading) => heading.textContent)).toEqual(["Ancestors", "Subtasks"]);
expect(ancestors.map((ancestor) => ancestor.id)).toEqual(["parent", "root"]);
act(() => (group.querySelector('button') as HTMLButtonElement).click());
expect(group.querySelector('a')).toBeNull();
expect(container.querySelector('[data-task-id="child"]')).not.toBeNull();
});
it("keeps ancestor-only tasks navigable instead of showing an empty state", () => {
render({ ancestors, subtasks: [], createdTasks: [], projects: [] });
expect(container.querySelectorAll('a')).toHaveLength(2);
expect(container.textContent).not.toContain("No tasks yet.");
});
it("hides the ancestor section for root tasks and preserves the empty state", () => {
render({ subtasks: [], createdTasks: [], projects: [] });
expect(container.querySelector('section[aria-label="Ancestors"]')).toBeNull();
expect(container.textContent).toContain("No tasks yet.");
});
it("keeps subtask membership separate from creation membership, including overlap", () => {
const manual = task("manual-child");
const overlap = task("created-child", { projectId: project.id });
@@ -6,7 +6,7 @@ import { projectRouteRef } from "@/lib/utils";
import { issueStatusOrder } from "@/lib/issue-filters";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { TaskDetailSubtasksPanel, TaskDetailTaskList } from "./TaskDetailRelationsPanel";
import { RelationNavigationList, TaskDetailSubtasksPanel, TaskDetailTaskList } from "./TaskDetailRelationsPanel";
function TaskGroup({ name, projectPath, children }: { name: string; projectPath?: string; children: ReactNode }) {
return (
@@ -34,6 +34,8 @@ function TaskGroup({ name, projectPath, children }: { name: string; projectPath?
}
export interface TaskDetailTasksPanelProps {
/** API order: immediate parent first. Displayed root first. */
ancestors?: NonNullable<Issue["ancestors"]>;
subtasks: Issue[];
createdTasks: Issue[];
projects: Project[];
@@ -43,7 +45,7 @@ export interface TaskDetailTasksPanelProps {
issueLinkState?: unknown;
}
export function TaskDetailTasksPanel({ subtasks, createdTasks, projects, isLoading, hasError, onRetry, issueLinkState }: TaskDetailTasksPanelProps) {
export function TaskDetailTasksPanel({ ancestors = [], subtasks, createdTasks, projects, isLoading, hasError, onRetry, issueLinkState }: TaskDetailTasksPanelProps) {
const sortedSubtasks = sortTasks(subtasks);
const groups = new Map<string, { name: string; path?: string; tasks: Issue[] }>();
for (const item of sortTasks(createdTasks)) {
@@ -61,6 +63,16 @@ export function TaskDetailTasksPanel({ subtasks, createdTasks, projects, isLoadi
}
return (
<section className="flex flex-col gap-6" aria-label="Related tasks">
{ancestors.length > 0 && (
<TaskGroup name="Ancestors">
<RelationNavigationList
items={[...ancestors].reverse()}
emptyMessage=""
ariaLabel="Ancestor tasks, root to parent"
issueLinkState={issueLinkState}
/>
</TaskGroup>
)}
{sortedSubtasks.length > 0 && (
<TaskGroup name="Subtasks">
<TaskDetailSubtasksPanel items={sortedSubtasks} issueLinkState={issueLinkState} />
@@ -78,7 +90,7 @@ export function TaskDetailTasksPanel({ subtasks, createdTasks, projects, isLoadi
{onRetry && <Button variant="ghost" size="sm" onClick={onRetry}>Retry</Button>}
</div>
)}
{!isLoading && !hasError && subtasks.length === 0 && createdTasks.length === 0 && (
{!isLoading && !hasError && ancestors.length === 0 && subtasks.length === 0 && createdTasks.length === 0 && (
<p className="py-6 text-center text-sm text-muted-foreground">No tasks yet.</p>
)}
</section>
+6 -4
View File
@@ -2260,8 +2260,9 @@ describe("IssueDetail", () => {
expect(panel?.querySelector('[data-slot="sheet-close"]')).not.toBeNull();
});
it("loads subtask membership and created work independently and refreshes on issue activity", async () => {
const source = createIssue();
it("loads ancestors, subtask membership and created work independently and refreshes on issue activity", async () => {
const ancestors = [{ id: "parent-task", identifier: "PAP-0", title: "Parent task", status: "in_progress" }] as Issue["ancestors"];
const source = createIssue({ ancestors });
const child = createIssue({ id: "manual-child", parentId: source.id, title: "Manual child" });
const created = createIssue({ id: "created-task", parentId: null, title: "Created elsewhere" });
mockIssuesApi.get.mockResolvedValue(source);
@@ -2274,7 +2275,8 @@ describe("IssueDetail", () => {
const taskProjection = () => mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children?.props.tasksTab;
expect(taskProjection()?.content.props.subtasks.map((row: Issue) => row.id)).toEqual([child.id]);
expect(taskProjection()?.content.props.createdTasks.map((row: Issue) => row.id)).toEqual([created.id]);
expect(taskProjection()?.count).toBe(2);
expect(taskProjection()?.content.props.ancestors).toEqual(ancestors);
expect(taskProjection()?.count).toBe(3);
const next = createIssue({ id: "new-created-task", parentId: source.id });
mockIssuesApi.list.mockImplementation((_companyId, filters?: { descendantOf?: string; createdFromIssueId?: string }) =>
@@ -2282,7 +2284,7 @@ describe("IssueDetail", () => {
);
await act(async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(source.companyId) }); });
await flushReact();
expect(taskProjection()?.count).toBe(3);
expect(taskProjection()?.count).toBe(4);
expect(taskProjection()?.content.props.createdTasks.map((row: Issue) => row.id)).toContain(next.id);
});
+6 -1
View File
@@ -3513,10 +3513,12 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
const createdTasks = createdTasksQuery.data ?? EMPTY_ISSUES;
const hasError = createdTasksQuery.isError || childIssuesError;
return {
count: new Set([...childIssues, ...createdTasks].map((task) => task.id)).size,
count: new Set([...(issue?.ancestors ?? []), ...childIssues, ...createdTasks].map((task) => task.id)).size,
hasError,
content: (
<TaskDetailTasksPanel
ancestors={issue?.ancestors}
issueLinkState={resolvedIssueDetailState ?? location.state}
subtasks={childIssues}
createdTasks={createdTasks}
projects={projects ?? []}
@@ -3531,6 +3533,9 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
};
}, [
tasksTab,
issue?.ancestors,
resolvedIssueDetailState,
location.state,
streamlinedTaskDetailEnabled,
childIssues,
childIssuesLoading,
@@ -50,6 +50,18 @@ function task(number: number, title: string, status: Issue["status"], parentId:
});
}
const rootTask = task(1800, "Make company work easy to follow", "in_progress", null);
const parentTask = task(1900, "Deliver the board experience", "in_review", rootTask.id);
function ancestor(issue: Issue): NonNullable<Issue["ancestors"]>[number] {
return {
id: issue.id, identifier: issue.identifier, title: issue.title,
description: issue.description, status: issue.status, priority: issue.priority,
assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId,
projectId: issue.projectId, goalId: issue.goalId, project: null, goal: null,
};
}
const sourceAncestors = [ancestor(parentTask), ancestor(rootTask)];
export const taskCandidates = [
task(1954, "Scoping review", "done", sourceTask.id, "origin-run-legacy"),
task(1964, "Phase 5 — UI polish", "in_progress", sourceTask.id),
@@ -102,8 +114,9 @@ export function SeedData({ children }: { children: React.ReactNode }) {
return ready ? children : null;
}
export function TasksPanel({ items }: { items: Issue[] }) {
export function TasksPanel({ items, ancestors = [] }: { items: Issue[]; ancestors?: NonNullable<Issue["ancestors"]> }) {
return <TaskDetailTasksPanel
ancestors={ancestors}
subtasks={items.filter((item) => item.parentId === sourceTask.id)}
createdTasks={items.filter((item) => item.originRunId && runSources.get(item.originRunId) === sourceTask.id)}
projects={storybookProjects}
@@ -123,10 +136,17 @@ sourceTask.planDocument = planDocument;
sourceTask.documentSummaries = [planDocument];
/** Only replaces data. Every full-page pixel is rendered by the production route. */
function TaskPageData({ children, scenario }: { children: React.ReactNode; scenario: Scenario }) {
function TaskPageData({ children, scenario, withAncestors }: { children: React.ReactNode; scenario: Scenario; withAncestors: boolean }) {
const client = useQueryClient();
const [fixture] = useState(() => {
const rows = [sourceTask, ...taskCandidates];
const rows = withAncestors
? [
{ ...sourceTask, parentId: parentTask.id, ancestors: sourceAncestors },
{ ...parentTask, ancestors: [ancestor(rootTask)] },
{ ...rootTask, ancestors: [] },
...taskCandidates,
]
: [sourceTask, ...taskCandidates];
const comments = scenario === "arrival" ? [] : [baseComments[1]!];
for (const row of rows) {
seedIssueDetailCache(client, row);
@@ -136,6 +156,7 @@ function TaskPageData({ children, scenario }: { children: React.ReactNode; scena
client.setQueryData([...queryKeys.issues.documents(ref), "plan"], row.id === sourceTask.id ? planDocument : null);
client.setQueryData(queryKeys.issues.liveRuns(ref), []);
client.setQueryData(queryKeys.issues.activeRun(ref), null);
client.setQueryData(["issues", "tree-control-state", ref], { activePauseHold: null });
}
}
client.setQueryData(queryKeys.health, { status: "ok", deploymentMode: "local_trusted", bootstrapStatus: "ready" });
@@ -183,6 +204,7 @@ function TaskPageData({ children, scenario }: { children: React.ReactNode; scena
}
if (resource === "documents/plan") return row.id === sourceTask.id ? Response.json(planDocument) : Response.json({ error: "No plan" }, { status: 404 });
if (resource === "documents") return Response.json(row.id === sourceTask.id ? [planDocument] : []);
if (resource === "tree-control/state") return Response.json({ activePauseHold: null });
if (resource === "active-run") return Response.json(null);
if (resource === "read") return Response.json({ ok: true });
if (["interactions", "attachments", "work-products", "live-runs", "runs", "feedback-votes", "activity", "approvals", "references"].includes(resource)) return Response.json([]);
@@ -215,15 +237,15 @@ function TaskRoute({ tasksTab }: { tasksTab?: React.ComponentProps<typeof IssueD
return <IssueDetail tasksTab={issueId === sourceTask.identifier ? tasksTab : undefined} />;
}
export function OriginatingTasksReview({ scenario = "mixed", fullPage = true, narrow = false, baseline = false }: { scenario?: Scenario; fullPage?: boolean; narrow?: boolean; baseline?: boolean }) {
export function OriginatingTasksReview({ scenario = "mixed", fullPage = true, narrow = false, baseline = false, withAncestors = false }: { scenario?: Scenario; fullPage?: boolean; narrow?: boolean; baseline?: boolean; withAncestors?: boolean }) {
const [items, setItems] = useState(() => scenarioTasks(scenario));
const tasksTab = useMemo(() => ({
count: items.length,
content: <TasksPanel items={items} />,
}), [items]);
count: items.length + (withAncestors ? sourceAncestors.length : 0),
content: <TasksPanel items={items} ancestors={withAncestors ? sourceAncestors : []} />,
}), [items, withAncestors]);
if (!fullPage) return <div className={cn("min-h-screen bg-background p-4 text-foreground", narrow ? "max-w-md" : "max-w-3xl")}>{tasksTab.content}</div>;
return (
<TaskPageData scenario={scenario}>
<TaskPageData scenario={scenario} withAncestors={withAncestors}>
{scenario === "arrival" && <div className="flex items-center gap-3 border-b border-border p-2 text-xs"><span>Story control</span><Button size="sm" variant="outline" disabled={items.length > 0} onClick={() => setItems(scenarioTasks("other").slice(0, 1))}>Simulate task creation</Button></div>}
<Routes>
<Route path="/:companyPrefix" element={<Layout />}>
@@ -10,6 +10,12 @@ components backed by local fixtures.
## Membership
- With Ancestors shows the production Tasks panel with a root task and immediate
parent above Subtasks. Ancestors Only, Ancestors Narrow and With Ancestors Light
cover a task without children, a narrow panel and light mode. Ancestors are
linked in root-to-parent order and collapse independently. The task detail
response already includes the chain; no additional ancestor query is needed.
- Subtasks includes the existing subtask tree, regardless of creator or run.
- Project and No project groups contain tasks created by runs originating from
the current task, regardless of current parentage. Created subtasks appear in
@@ -25,3 +25,8 @@ export const AllFinished: Story = { args: { fullPage: false, scenario: "complete
export const Empty: Story = { args: { fullPage: false, scenario: "empty" } };
export const NarrowPanel: Story = { args: { fullPage: false, narrow: true }, globals: { viewport: { value: "mobile" } } };
export const FullTaskPageLight: Story = { globals: { theme: "light" } };
export const WithAncestors: Story = { args: { withAncestors: true } };
export const AncestorsOnly: Story = { args: { withAncestors: true, fullPage: false, scenario: "empty" } };
export const AncestorsNarrow: Story = { args: { withAncestors: true, fullPage: false, narrow: true } };
export const WithAncestorsLight: Story = { args: { withAncestors: true }, globals: { theme: "light" } };