mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
## 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>
242 lines
7.6 KiB
TypeScript
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?: string | null;
|
|
}
|
|
|
|
export 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>
|
|
);
|
|
}
|