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" } };