mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-07 16:11:46 +02:00
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 at3d78e3a4e. Rebased onto5f1100e3bbefore 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:
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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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" } };
|
||||
Reference in new issue
Block a user