From 2788f20fc06cd4bb31f8d95fe453cac64ee7092b Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:47:10 -0500 Subject: [PATCH] fix(ui): show ancestors in the task detail Tasks panel (#13823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../task-detail/TaskDetailRelationsPanel.tsx | 4 +- .../task-detail/TaskDetailTasksPanel.test.tsx | 30 +++++++++++++++ .../task-detail/TaskDetailTasksPanel.tsx | 18 +++++++-- ui/src/pages/IssueDetail.test.tsx | 10 +++-- ui/src/pages/IssueDetail.tsx | 7 +++- .../originating-tasks/OriginatingTasks.tsx | 38 +++++++++++++++---- .../prototypes/originating-tasks/README.md | 6 +++ .../stories/originating-tasks.stories.tsx | 5 +++ 8 files changed, 100 insertions(+), 18 deletions(-) diff --git a/ui/src/components/task-detail/TaskDetailRelationsPanel.tsx b/ui/src/components/task-detail/TaskDetailRelationsPanel.tsx index 1530482a04..855fa0a13e 100644 --- a/ui/src/components/task-detail/TaskDetailRelationsPanel.tsx +++ b/ui/src/components/task-detail/TaskDetailRelationsPanel.tsx @@ -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, diff --git a/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx b/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx index d8af2ea7e2..1e233452a5 100644 --- a/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx +++ b/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx @@ -19,7 +19,37 @@ function render(props: React.ComponentProps) { act(() => root.render()); } +const ancestors = [ + { id: "parent", identifier: "PAP-2", title: "Immediate parent", status: "in_progress" }, + { id: "root", identifier: null, title: "Root task", status: "done" }, +] as NonNullable; + 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 }); diff --git a/ui/src/components/task-detail/TaskDetailTasksPanel.tsx b/ui/src/components/task-detail/TaskDetailTasksPanel.tsx index 0a90ddca77..cb48ebe519 100644 --- a/ui/src/components/task-detail/TaskDetailTasksPanel.tsx +++ b/ui/src/components/task-detail/TaskDetailTasksPanel.tsx @@ -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; 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(); for (const item of sortTasks(createdTasks)) { @@ -61,6 +63,16 @@ export function TaskDetailTasksPanel({ subtasks, createdTasks, projects, isLoadi } return (
+ {ancestors.length > 0 && ( + + + + )} {sortedSubtasks.length > 0 && ( @@ -78,7 +90,7 @@ export function TaskDetailTasksPanel({ subtasks, createdTasks, projects, isLoadi {onRetry && } )} - {!isLoading && !hasError && subtasks.length === 0 && createdTasks.length === 0 && ( + {!isLoading && !hasError && ancestors.length === 0 && subtasks.length === 0 && createdTasks.length === 0 && (

No tasks yet.

)}
diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 3e5a8f8e21..e9159c1d62 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -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); }); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 63a6a6072c..5ac8168873 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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: ( [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 }) { return 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; } -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: , - }), [items]); + count: items.length + (withAncestors ? sourceAncestors.length : 0), + content: , + }), [items, withAncestors]); if (!fullPage) return
{tasksTab.content}
; return ( - + {scenario === "arrival" &&
Story control
} }> diff --git a/ui/storybook/prototypes/originating-tasks/README.md b/ui/storybook/prototypes/originating-tasks/README.md index 8922801c0f..5344da87cf 100644 --- a/ui/storybook/prototypes/originating-tasks/README.md +++ b/ui/storybook/prototypes/originating-tasks/README.md @@ -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 diff --git a/ui/storybook/stories/originating-tasks.stories.tsx b/ui/storybook/stories/originating-tasks.stories.tsx index 4ac6d90512..4d4ee45194 100644 --- a/ui/storybook/stories/originating-tasks.stories.tsx +++ b/ui/storybook/stories/originating-tasks.stories.tsx @@ -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" } };