Files
PaperClipAI/ui/src/components/task-detail/TaskDetailRelationsPanel.tsx
T
DottaandPaperclip 7b829efdf6 feat: show tasks created from a task by project (#13241)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A task can cause an agent to create more tasks.
> - Those tasks can belong to other projects or have another parent.
> - The subtask view does not show all work created from the current
task.
> - This pull request adds a Tasks tab with separate subtask and
creation groups.
> - Operators can follow created work without changing its parent or
project.

## Linked Issues or Issue Description

**Problem or motivation**

Operators need to see all work that an agent creates while running for a
task. Parentage alone does not describe this relationship. Legacy and
native runs must follow the same rules.

**Proposed solution**

Show all subtasks in one section. Separately group tasks created from
the source task by their current project, with a No project group when
needed. A created subtask appears in both sections. Use saved run
context and recorded creation activity to find the source task.

**Alternatives considered**

Making all created tasks children would change their hierarchy. Removing
overlap between sections would hide the creation relationship. This
change keeps the two memberships separate.

**Roadmap alignment**

This extends the existing Activity log & action attribution capability.
It does not add a new roadmap area. Related PR: #9727 adds a stored
source-task field and inbound attribution UI. This PR adds the outgoing
task list using existing run and activity records and does not require
that schema change.

## What Changed

- Add a company-scoped createdFromIssueId filter to issue lists.
- Save the actor run during task creation, including legacy child-helper
calls.
- Recover historical run attribution from creation activity when the
origin run is absent.
- Render the production Tasks panel with all subtasks and independently
grouped created work.
- Keep progress only for subtasks. Add folding, hover fades and project
links.
- Fetch all result pages and refresh on issue activity. Show load
failures with Retry.
- Add database, API, UI and pagination tests, design-guide examples and
Storybook pages.

## Verification

- Before rebase: 158 targeted tests passed. Workspace typecheck,
UI/server builds, Storybook build and token gates passed.
- After rebase: full workspace typecheck and build passed. The cursor
fix passes 26 focused tests and UI/server typechecks.
- The full local test command completed its general-server group with
10,563 passing tests and two failures: a missing native-runner fixture
and a concurrency-test timeout. Building the fixture and rerunning both
affected files passed all 41 tests. The local command stopped before its
remaining groups; all corresponding GitHub test shards passed on the
submitted head.
- GitHub checks on commit 3b4bf0bd8: 31 passed, including the aggregate
CI gate, all server/workspace/browser test shards, typecheck, build,
runner verification, canary dry run, policy and security checks. Two
conditional Storybook jobs were skipped by the workflow.
- Greptile reviewed commit 3b4bf0bd8 at 5/5 with no unresolved comments.
- Open Storybook at UX Labs / Tasks Created From a Task / Full Task
Page. Check that a created subtask appears in both sections. Fold each
group and use a project link. Check the No project group and first-task
arrival story.

## Risks

- Old tasks without a saved origin run or attributed creation activity
cannot be linked to a source. The code does not infer a source from a
shared creator or a comment.
- The new list filter reads run and activity records. Source, run,
activity and result stay within the requested company.
- Task creation now saves the actor run when no explicit origin run is
supplied. Existing explicit origins remain unchanged. No database
migration is required.

## Model Used

- OpenAI GPT-6 through Codex. The runtime identifies the model family as
GPT-6 but does not expose a more specific model ID or context-window
size. Used reasoning, repository tools, code execution and browser
inspection.

## 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-11 12:39:08 -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?: IssueStatus | null;
}
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>
);
}