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>
This commit is contained in:
Dotta 2026-09-11 12:39:08 -05:00 committed by GitHub
parent 5545f6d166
commit 7b829efdf6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 877 additions and 20 deletions

View File

@ -247,6 +247,9 @@ See `doc/project-repositories.md` for the API and UI contract.
- `created_by_user_id` uuid fk `users.id` null
- identifier fields: `issue_number`, `identifier`
- origin fields: `origin_kind`, `origin_id`, `origin_run_id`, `origin_fingerprint`
- Creation stores the actor run in `origin_run_id` unless an explicit origin run is supplied. `GET /api/companies/:companyId/issues?createdFromIssueId=<uuid>` selects tasks created by runs bound to that source task, using native run issue identity or persisted legacy task context. Historical rows without an origin run may use their recorded creation activity; comments and shared creators do not establish provenance. Source, run, activity and result are company-scoped.
- Relation lists can use `sortField=id&sortDir=asc&afterId=<uuid>` for stable pagination. The cursor excludes earlier IDs and cannot be combined with an offset or activity-based order.
- The streamlined task page's Tasks tab keeps two independent memberships: the existing subtask tree, and created tasks grouped by their current project (or No project). A created subtask appears in both. Only Subtasks has completion progress; groups collapse independently and unfinished tasks sort above finished tasks.
- `request_depth` int not null default 0
- `work_mode` text not null default `standard`; supported values:
- `standard`: normal autonomous execution. Agents may investigate, edit files, create artifacts, and complete the task.

View File

@ -176,6 +176,11 @@ When a task originates from a cross-team request, track the **depth** as an inte
#### Billing Codes
Task detail keeps hierarchy separate from creation provenance: the Tasks tab shows
all subtasks and, independently, work created from the current task grouped by
project or No project. A created subtask may appear in both sections. Creation
provenance follows the originating run equally for legacy and native runners.
Tasks carry a **billing code** so that token spend during execution can be attributed upstream to the requesting task/agent. When Agent A asks Agent B to do work, the cost of B's work is tracked against A's request. This enables cost attribution across the org.
### Open Questions

View File

@ -0,0 +1,156 @@
import { randomUUID } from "node:crypto";
import express from "express";
import { eq } from "drizzle-orm";
import request from "supertest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { activityLog, agents, companies, createDb, heartbeatRuns, issues, projects } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { issueRoutes } from "../routes/issues.js";
import { issueService } from "../services/issues.js";
import { errorHandler } from "../middleware/index.js";
const support = await getEmbeddedPostgresTestSupport();
const describePostgres = support.supported ? describe : describe.skip;
describePostgres("tasks created from an issue", () => {
let db: ReturnType<typeof createDb>;
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
const companyId = randomUUID();
const otherCompanyId = randomUUID();
const sourceId = randomUUID();
const otherSourceId = randomUUID();
const agentId = randomUUID();
const legacyRunId = randomUUID();
const nativeRunId = randomUUID();
const taskIdRunId = randomUUID();
const taskKeyRunId = randomUUID();
const unrelatedRunId = randomUUID();
const foreignRunId = randomUUID();
const missingContextRunId = randomUUID();
const foreignSourceId = randomUUID();
const expected = new Set<string>();
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("paperclip-created-from-");
db = createDb(database.connectionString);
await db.insert(companies).values([
{ id: companyId, name: "Origin", issuePrefix: "ORG", defaultResponsibleUserId: "board-user" },
{ id: otherCompanyId, name: "Other", issuePrefix: "OTH", defaultResponsibleUserId: "board-user" },
]);
const foreignAgentId = randomUUID();
await db.insert(agents).values([
{ id: agentId, companyId, name: "Coder", role: "engineer", adapterType: "codex_local" },
{ id: foreignAgentId, companyId: otherCompanyId, name: "Other coder", role: "engineer", adapterType: "codex_local" },
]);
await db.insert(issues).values([
{ id: sourceId, companyId, title: "Source", identifier: "ORG-1", issueNumber: 1 },
{ id: otherSourceId, companyId, title: "Other source", identifier: "ORG-2", issueNumber: 2 },
{ id: foreignSourceId, companyId: otherCompanyId, title: "Foreign source", identifier: "OTH-1" },
]);
await db.insert(heartbeatRuns).values([
{ id: legacyRunId, companyId, agentId, contextSnapshot: { issueId: sourceId }, responsibleUserId: "board-user" },
{ id: nativeRunId, companyId, agentId, runtimeMode: "native", nativeIssueId: sourceId, contextSnapshot: { issueId: otherSourceId } },
{ id: taskIdRunId, companyId, agentId, contextSnapshot: { taskId: sourceId } },
{ id: taskKeyRunId, companyId, agentId, contextSnapshot: { taskKey: "ORG-1" } },
{ id: unrelatedRunId, companyId, agentId, contextSnapshot: { issueId: otherSourceId, taskKey: "ORG-1" } },
{ id: foreignRunId, companyId: otherCompanyId, agentId: foreignAgentId, contextSnapshot: { issueId: sourceId } },
{ id: missingContextRunId, companyId, agentId, contextSnapshot: {} },
]);
const projectId = randomUUID();
await db.insert(projects).values({ id: projectId, companyId, name: "Cross-project" });
const rows = [
{ title: "Legacy top-level", originRunId: legacyRunId, projectId },
{ title: "Native child", originRunId: nativeRunId, parentId: sourceId },
{ title: "Different parent", originRunId: taskIdRunId, parentId: otherSourceId },
{ title: "No project, completed", originRunId: taskKeyRunId, status: "done" },
].map((row) => ({ id: randomUUID(), companyId, createdByAgentId: agentId, ...row }));
rows.forEach((row) => expected.add(row.id));
const historicalId = randomUUID();
const commentedId = randomUUID();
expected.add(historicalId);
await db.insert(issues).values([
...rows,
{ id: historicalId, companyId, title: "Historical child helper", parentId: otherSourceId },
{ id: commentedId, companyId, title: "Only commented on by source" },
{ companyId, title: "Manual child", parentId: sourceId },
{ companyId, title: "Same agent, unrelated run", createdByAgentId: agentId, originRunId: unrelatedRunId },
{ companyId, title: "Hidden", originRunId: legacyRunId, hiddenAt: new Date() },
{ companyId, title: "No context", originRunId: missingContextRunId },
{ companyId, title: "Wrong run company", originRunId: foreignRunId },
{ companyId: otherCompanyId, title: "Wrong issue company", originRunId: legacyRunId },
]);
await db.insert(activityLog).values([
{ companyId, actorType: "agent", actorId: agentId, runId: legacyRunId, action: "issue.child_created", entityType: "issue", entityId: historicalId },
{ companyId, actorType: "agent", actorId: agentId, runId: legacyRunId, action: "issue.comment_added", entityType: "issue", entityId: commentedId },
]);
}, 30_000);
afterAll(async () => { await database?.cleanup(); });
function app() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = { type: "board", userId: "board-user", source: "local_implicit", isInstanceAdmin: true };
next();
});
app.use("/api", issueRoutes(db, {} as never));
app.use(errorHandler);
return app;
}
it("returns both runners' created tasks, regardless of parent, project or completion", async () => {
for (const view of [undefined, "compact"]) {
const result = await request(app()).get(`/api/companies/${companyId}/issues`).query({ createdFromIssueId: sourceId, ...(view ? { view } : {}) });
expect(result.status, JSON.stringify(result.body)).toBe(200);
expect(new Set(result.body.map((row: { id: string }) => row.id))).toEqual(expected);
}
});
it("keeps cursor pages complete when activity changes or an earlier task disappears", async () => {
const first = await request(app()).get(`/api/companies/${companyId}/issues`).query({ createdFromIssueId: sourceId, sortField: "id", sortDir: "asc", limit: 2 });
expect(first.status).toBe(200);
const sortedIds = [...expected].sort();
expect(first.body.map((row: { id: string }) => row.id)).toEqual(sortedIds.slice(0, 2));
await db.update(issues).set({ updatedAt: new Date("2099-01-01"), priority: "critical" }).where(eq(issues.id, sortedIds.at(-1)!));
await db.update(issues).set({ hiddenAt: new Date() }).where(eq(issues.id, sortedIds[0]!));
try {
const remaining = await request(app()).get(`/api/companies/${companyId}/issues`).query({ createdFromIssueId: sourceId, sortField: "id", sortDir: "asc", afterId: first.body.at(-1).id, limit: 500 });
expect(remaining.status).toBe(200);
expect(remaining.body.map((row: { id: string }) => row.id)).toEqual(sortedIds.slice(2));
} finally {
await db.update(issues).set({ hiddenAt: null }).where(eq(issues.id, sortedIds[0]!));
}
});
it("rejects cursors without matching ID order", async () => {
for (const query of [{ afterId: sourceId }, { afterId: "bad", sortField: "id", sortDir: "asc" }, { afterId: sourceId, sortField: "id", sortDir: "desc" }, { afterId: sourceId, sortField: "id", sortDir: "asc", offset: 2 }]) {
const result = await request(app()).get(`/api/companies/${companyId}/issues`).query(query);
expect(result.status).toBe(422);
}
});
it("does not include manual children in creation provenance", async () => {
const children = await issueService(db).list(companyId, { parentId: sourceId });
expect(children.map((row) => row.title)).toContain("Manual child");
expect(children.map((row) => row.title)).toContain("Native child");
});
it("rejects malformed filters and never matches a source from another company", async () => {
const invalid = await request(app()).get(`/api/companies/${companyId}/issues`).query({ createdFromIssueId: "not-an-id" });
expect(invalid.status).toBe(422);
const foreign = await request(app()).get(`/api/companies/${companyId}/issues`).query({ createdFromIssueId: foreignSourceId });
expect(foreign.status).toBe(200);
expect(foreign.body).toEqual([]);
});
it("saves legacy child-helper creation provenance from the actor run", async () => {
const created = await issueService(db).createChild(otherSourceId, {
title: "Created while working on source, under another parent",
actorRunId: legacyRunId,
createdByAgentId: agentId,
});
expect(created.issue.originRunId).toBe(legacyRunId);
const matches = await issueService(db).list(companyId, { createdFromIssueId: sourceId });
expect(matches.map((row) => row.id)).toContain(created.issue.id);
});
});

View File

@ -7847,10 +7847,10 @@ export function issueRoutes(
res.status(400).json({ error: "offset must be a non-negative integer" });
return;
}
if (sortField !== undefined && sortField !== "updated") {
if (sortField !== undefined && sortField !== "updated" && sortField !== "id") {
res
.status(400)
.json({ error: "sortField must be 'updated' when provided" });
.json({ error: "sortField must be 'updated' or 'id' when provided" });
return;
}
if (sortDir !== undefined && sortDir !== "asc" && sortDir !== "desc") {
@ -7920,6 +7920,7 @@ export function issueRoutes(
parentId: (req.query.parentId ?? req.query.parentIssueId) as
string | undefined,
descendantOf: req.query.descendantOf as string | undefined,
createdFromIssueId: req.query.createdFromIssueId as string | undefined,
labelId: req.query.labelId as string | undefined,
originKind: req.query.originKind as string | undefined,
originKindPrefix: req.query.originKindPrefix as string | undefined,
@ -7944,7 +7945,8 @@ export function issueRoutes(
q: req.query.q as string | undefined,
limit,
offset,
sortField: sortField === "updated" ? "updated" : undefined,
sortField: sortField === "updated" || sortField === "id" ? sortField : undefined,
afterId: req.query.afterId as string | undefined,
sortDir: sortDir === "asc" || sortDir === "desc" ? sortDir : undefined,
updatedSince: rawUpdatedSince,
};
@ -8143,6 +8145,7 @@ export function issueRoutes(
parentId: (req.query.parentId ?? req.query.parentIssueId) as
string | undefined,
descendantOf: req.query.descendantOf as string | undefined,
createdFromIssueId: req.query.createdFromIssueId as string | undefined,
labelId: req.query.labelId as string | undefined,
originKind: req.query.originKind as string | undefined,
originKindPrefix: req.query.originKindPrefix as string | undefined,

View File

@ -0,0 +1,41 @@
import { sql } from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import { activityLog, heartbeatRuns, issues } from "@paperclipai/db";
import { isUuidLike } from "@paperclipai/shared";
import { unprocessable } from "../errors.js";
/** Creation provenance is independent of the task's current parent or project. */
export function createdFromIssueCondition(companyId: string, sourceIssueId: string) {
if (!isUuidLike(sourceIssueId)) {
throw unprocessable("createdFromIssueId must be a UUID");
}
const source = alias(issues, "creation_source_issue");
// Resolve source runs independently of the candidate tasks so the database
// can reuse this set, rather than scan all company runs for each task.
const originatingRuns = sql`
SELECT ${heartbeatRuns.id}::text FROM ${heartbeatRuns}
INNER JOIN ${issues} AS ${source} ON ${source.id} = ${sourceIssueId}
AND ${source.companyId} = ${companyId}
WHERE ${heartbeatRuns.companyId} = ${companyId}
AND coalesce(
${heartbeatRuns.nativeIssueId}::text,
nullif(${heartbeatRuns.contextSnapshot}->>'issueId', ''),
nullif(${heartbeatRuns.contextSnapshot}->>'taskId', ''),
nullif(${heartbeatRuns.contextSnapshot}->>'taskKey', '')
) IN (${source.id}::text, ${source.identifier})
`;
return sql<boolean>`
${issues.id} <> ${sourceIssueId}
AND (
${issues.originRunId} IN (${originatingRuns})
OR (${issues.originRunId} IS NULL AND EXISTS (
SELECT 1 FROM ${activityLog}
WHERE ${activityLog.companyId} = ${companyId}
AND ${activityLog.entityType} = 'issue'
AND ${activityLog.entityId} = ${issues.id}::text
AND ${activityLog.action} IN ('issue.created', 'issue.child_created')
AND ${activityLog.runId}::text IN (${originatingRuns})
))
)
`;
}

View File

@ -1,3 +1,4 @@
import { createdFromIssueCondition } from "./issue-creation-origin.js";
import { executionProjectionsForRuns } from "./execution-projection.js";
import type { ExecutionProjection } from "@paperclipai/shared";
import { Buffer } from "node:buffer";
@ -1748,6 +1749,7 @@ export interface IssueFilters {
executionWorkspaceId?: string;
parentId?: string;
descendantOf?: string;
createdFromIssueId?: string;
labelId?: string;
originKind?: string;
originKindPrefix?: string;
@ -1763,7 +1765,8 @@ export interface IssueFilters {
q?: string;
limit?: number;
offset?: number;
sortField?: "updated";
sortField?: "updated" | "id";
afterId?: string;
sortDir?: "asc" | "desc";
/** ISO 8601 timestamp — only return issues with updatedAt strictly after this value. */
updatedSince?: string;
@ -3112,6 +3115,7 @@ function issueListOrderBy(
sortDir?: IssueFilters["sortDir"];
},
) {
if (sortField === "id") return [sortDir === "desc" ? desc(issues.id) : asc(issues.id)];
const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId);
if (sortField === "updated") {
const activityOrder =
@ -6240,6 +6244,9 @@ async function blockedInboxIssueConditions(
const contextUserId =
unreadForUserId ?? touchedByUserId ?? inboxArchivedByUserId;
if (filters?.createdFromIssueId) {
conditions.push(createdFromIssueCondition(companyId, filters.createdFromIssueId));
}
if (filters?.descendantOf) {
conditions.push(sql<boolean>`
${issues.id} IN (
@ -7873,6 +7880,15 @@ export function issueService(db: Db) {
addStopRelayCommentIfNeeded,
list: async (companyId: string, filters?: IssueFilters) => {
if (filters?.sortField === "id" && filters.attention) {
throw unprocessable("ID ordering is not supported for blocked attention lists");
}
if (filters?.afterId !== undefined && (
!isUuidLike(filters.afterId) || filters.sortField !== "id" ||
filters.sortDir !== "asc" || (filters.offset ?? 0) !== 0
)) {
throw unprocessable("afterId requires a UUID, ascending ID order and no offset");
}
if (filters?.attention === "blocked") {
return listBlockedInboxIssues(db, companyId, {
...filters,
@ -7885,6 +7901,7 @@ export function issueService(db: Db) {
eq(issues.companyId, companyId),
visibleIssueCondition(),
];
if (filters?.afterId) conditions.push(gt(issues.id, filters.afterId));
const assigneeAgentFilter = parseIssueAssigneeAgentFilter(
filters?.assigneeAgentId,
);
@ -7928,6 +7945,9 @@ export function issueService(db: Db) {
AND ${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'
)
`;
if (filters?.createdFromIssueId) {
conditions.push(createdFromIssueCondition(companyId, filters.createdFromIssueId));
}
if (filters?.descendantOf) {
conditions.push(sql<boolean>`
${issues.id} IN (
@ -10154,6 +10174,7 @@ export function issueService(db: Db) {
const values = {
...issueData,
originRunId: issueData.originRunId ?? actorRunId ?? null,
responsibleUserId,
requestDepth: clampIssueRequestDepth(issueData.requestDepth),
originKind: issueData.originKind ?? "manual",

View File

@ -29,6 +29,15 @@ describe("issuesApi.list", () => {
mockApi.patch.mockResolvedValue({});
});
it("fetches all pages of tasks created from the source without filtering parentage", async () => {
const firstPage = Array.from({ length: 500 }, (_, index) => ({ id: `task-${index}` }));
mockApi.get.mockResolvedValueOnce(firstPage).mockResolvedValueOnce([{ id: "last-task" }]);
const result = await issuesApi.listAll("company-1", { createdFromIssueId: "source-1" });
expect(result).toHaveLength(501);
expect(mockApi.get).toHaveBeenNthCalledWith(1, "/companies/company-1/issues?createdFromIssueId=source-1&limit=500&sortField=id&sortDir=asc");
expect(mockApi.get).toHaveBeenNthCalledWith(2, "/companies/company-1/issues?createdFromIssueId=source-1&limit=500&sortField=id&sortDir=asc&afterId=task-499");
});
it("passes parentId through to the company issues endpoint", async () => {
await issuesApi.list("company-1", {
parentId: "issue-parent-1",

View File

@ -96,6 +96,7 @@ export type IssueListFilters = {
originKindPrefix?: string;
originId?: string;
descendantOf?: string;
createdFromIssueId?: string;
includeRoutineExecutions?: boolean;
includeBlockedBy?: boolean;
includeBlockedInboxAttention?: boolean;
@ -104,7 +105,8 @@ export type IssueListFilters = {
q?: string;
limit?: number;
offset?: number;
sortField?: "updated";
sortField?: "updated" | "id";
afterId?: string;
sortDir?: "asc" | "desc";
};
@ -135,6 +137,7 @@ function issueListSearchParams(filters?: IssueListFilters) {
params.set("originKindPrefix", filters.originKindPrefix);
if (filters?.originId) params.set("originId", filters.originId);
if (filters?.descendantOf) params.set("descendantOf", filters.descendantOf);
if (filters?.createdFromIssueId) params.set("createdFromIssueId", filters.createdFromIssueId);
if (filters?.includeRoutineExecutions)
params.set("includeRoutineExecutions", "true");
if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true");
@ -151,10 +154,23 @@ function issueListSearchParams(filters?: IssueListFilters) {
params.set("offset", String(filters.offset));
if (filters?.sortField) params.set("sortField", filters.sortField);
if (filters?.sortDir) params.set("sortDir", filters.sortDir);
if (filters?.afterId) params.set("afterId", filters.afterId);
return params;
}
export const issuesApi = {
/** Fetch every page for bounded task-detail relations, not just the default first page. */
listAll: async (companyId: string, filters: Omit<IssueListFilters, "limit" | "offset" | "sortField" | "sortDir" | "afterId" | "attention">, options?: RequestOptions): Promise<Issue[]> => {
const pageSize = 500;
const tasks = new Map<string, Issue>();
let afterId: string | undefined;
for (;;) {
const page = await issuesApi.list(companyId, { ...filters, limit: pageSize, sortField: "id", sortDir: "asc", afterId }, options);
for (const task of page) tasks.set(task.id, task);
if (page.length < pageSize) return [...tasks.values()];
afterId = page[page.length - 1]!.id;
}
},
list: (
companyId: string,
filters?: IssueListFilters,

View File

@ -86,7 +86,7 @@ export function resolveTaskDetailSubtaskState(items: Issue[]) {
};
}
function SharedSubtaskList({
export function TaskDetailTaskList({
items,
ariaLabel,
issueLinkState,
@ -166,7 +166,7 @@ export function TaskDetailSubtasksPanel({
<h3 id="task-next-action-heading" className="text-xs font-medium text-muted-foreground">
{nextAction.status === "blocked" ? "Blocked subtask" : "Next action"}
</h3>
<SharedSubtaskList
<TaskDetailTaskList
items={[nextAction]}
ariaLabel="Next subtask action"
issueLinkState={issueLinkState}
@ -185,7 +185,7 @@ export function TaskDetailSubtasksPanel({
<h3 id="task-other-subtasks-heading" className="text-xs font-medium text-muted-foreground">
{nextAction ? "Other subtasks" : "Subtasks"}
</h3>
<SharedSubtaskList
<TaskDetailTaskList
items={remainingItems}
ariaLabel={nextAction ? "Other subtasks" : "Subtasks"}
issueLinkState={issueLinkState}

View File

@ -0,0 +1,63 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Issue, Project } from "@paperclipai/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TaskDetailTasksPanel } from "./TaskDetailTasksPanel";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.mock("@/lib/router", () => ({ Link: ({ to, children, ...props }: { to: string; children: React.ReactNode }) => <a href={to} {...props}>{children}</a> }));
vi.mock("@/components/IssueRow", () => ({ IssueRow: ({ issue }: { issue: Issue }) => <span data-task-id={issue.id}>{issue.title}</span> }));
const task = (id: string, overrides: Partial<Issue> = {}) => ({ id, title: id, status: "todo", projectId: null, ...overrides }) as Issue;
const project = { id: "project-1", name: "Board UI", urlKey: "board-ui" } as Project;
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
afterEach(() => { act(() => root?.unmount()); container?.remove(); });
function render(props: React.ComponentProps<typeof TaskDetailTasksPanel>) {
container = document.createElement("div"); document.body.append(container);
root = createRoot(container);
act(() => root.render(<TaskDetailTasksPanel {...props} />));
}
describe("TaskDetailTasksPanel", () => {
it("keeps subtask membership separate from creation membership, including overlap", () => {
const manual = task("manual-child");
const overlap = task("created-child", { projectId: project.id });
const followup = task("other-parent", { parentId: "elsewhere", projectId: project.id });
render({ subtasks: [manual, overlap], createdTasks: [overlap, followup], projects: [project] });
expect(container.querySelectorAll('[data-task-id="created-child"]')).toHaveLength(2);
expect(container.querySelectorAll('[data-task-id="manual-child"]')).toHaveLength(1);
const group = container.querySelector('section[aria-label="Board UI"]')!;
expect(group.textContent).toContain("other-parent");
expect(group.textContent).not.toContain("manual-child");
expect(container.querySelectorAll('[role="progressbar"]')).toHaveLength(1);
});
it("hides absent subtasks and puts unowned tasks under No project without a link or progress", () => {
render({ subtasks: [], createdTasks: [task("unowned", { project: project })], projects: [project] });
expect(container.textContent).not.toContain("Subtasks");
expect(container.querySelector('section[aria-label="No project"]')).not.toBeNull();
expect(container.querySelectorAll('a, [role="progressbar"]')).toHaveLength(0);
});
it("sorts unfinished before completed work, deduplicates within groups, and folds independently", () => {
const done = task("done", { status: "done", projectId: project.id });
const unfinished = task("unfinished", { projectId: project.id });
render({ subtasks: [task("child")], createdTasks: [done, unfinished, done], projects: [project] });
const group = container.querySelector('section[aria-label="Board UI"]')!;
expect([...group.querySelectorAll('[data-task-id]')].map((row) => row.getAttribute("data-task-id"))).toEqual(["unfinished", "done"]);
expect(group.querySelector('a')?.getAttribute("href")).toBe("/projects/board-ui/issues");
act(() => (group.querySelector('button') as HTMLButtonElement).click());
expect(group.querySelector('[data-task-id]')).toBeNull();
expect(container.querySelector('[data-task-id="child"]')).not.toBeNull();
});
it("surfaces query failures without hiding available subtasks", () => {
const retry = vi.fn();
render({ subtasks: [task("child")], createdTasks: [], projects: [], hasError: true, onRetry: retry });
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not load all tasks");
act(() => [...container.querySelectorAll('button')].find((button) => button.textContent === "Retry")!.click());
expect(retry).toHaveBeenCalledOnce();
expect(container.querySelector('[data-task-id="child"]')).not.toBeNull();
});
});

View File

@ -0,0 +1,92 @@
import type { ReactNode } from "react";
import type { Issue, Project } from "@paperclipai/shared";
import { ArrowUpRight, ChevronRight } from "lucide-react";
import { Link } from "@/lib/router";
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";
function TaskGroup({ name, projectPath, children }: { name: string; projectPath?: string; children: ReactNode }) {
return (
<Collapsible defaultOpen asChild>
<section aria-label={name}>
<div className="group/header flex items-center gap-1 rounded-md hover:bg-accent/50">
<h2>
<CollapsibleTrigger className="group flex items-center gap-1 rounded-md py-1 text-left text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span>{name}</span>
<span aria-hidden className="flex w-3.5 items-center justify-center opacity-0 transition-opacity duration-(--motion-duration-fast) ease-(--motion-ease-standard) group-hover/header:opacity-100 group-focus-within/header:opacity-100">
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground transition-transform duration-(--motion-duration-fast) ease-(--motion-ease-standard) group-data-[state=open]:rotate-90" />
</span>
</CollapsibleTrigger>
</h2>
{projectPath && (
<Link to={projectPath} aria-label={`Go to ${name} project`} title={`Go to ${name} project`} className="ml-auto inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity duration-(--motion-duration-fast) ease-(--motion-ease-standard) hover:bg-accent hover:text-foreground group-hover/header:opacity-100 group-focus-within/header:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<ArrowUpRight aria-hidden className="h-3.5 w-3.5" />
</Link>
)}
</div>
<CollapsibleContent className="pt-2">{children}</CollapsibleContent>
</section>
</Collapsible>
);
}
export interface TaskDetailTasksPanelProps {
subtasks: Issue[];
createdTasks: Issue[];
projects: Project[];
isLoading?: boolean;
hasError?: boolean;
onRetry?: () => void;
issueLinkState?: unknown;
}
export function TaskDetailTasksPanel({ 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)) {
const key = item.projectId ?? "no-project";
const project = item.projectId
? projects.find((candidate) => candidate.id === item.projectId) ?? item.project
: null;
const group = groups.get(key) ?? {
name: project?.name ?? (item.projectId ? "Project" : "No project"),
path: item.projectId ? `/projects/${projectRouteRef(project ?? { id: item.projectId })}/issues` : undefined,
tasks: [],
};
group.tasks.push(item);
groups.set(key, group);
}
return (
<section className="flex flex-col gap-6" aria-label="Related tasks">
{sortedSubtasks.length > 0 && (
<TaskGroup name="Subtasks">
<TaskDetailSubtasksPanel items={sortedSubtasks} issueLinkState={issueLinkState} />
</TaskGroup>
)}
{[...groups.entries()].sort(([, a], [, b]) => a.name.localeCompare(b.name)).map(([id, group]) => (
<TaskGroup key={id} name={group.name} projectPath={group.path}>
<TaskDetailTaskList items={group.tasks} ariaLabel={`${group.name} tasks`} issueLinkState={issueLinkState} />
</TaskGroup>
))}
{isLoading && <p role="status" className="text-sm text-muted-foreground">Loading tasks</p>}
{hasError && (
<div role="alert" className="flex items-center gap-2 text-sm text-destructive">
<span>Could not load all tasks.</span>
{onRetry && <Button variant="ghost" size="sm" onClick={onRetry}>Retry</Button>}
</div>
)}
{!isLoading && !hasError && subtasks.length === 0 && createdTasks.length === 0 && (
<p className="py-6 text-center text-sm text-muted-foreground">No tasks yet.</p>
)}
</section>
);
}
function sortTasks(items: Issue[]) {
return [...new Map(items.map((item) => [item.id, item])).values()].sort((a, b) =>
issueStatusOrder.indexOf(a.status) - issueStatusOrder.indexOf(b.status),
);
}

View File

@ -195,6 +195,36 @@ describe("TaskSidePanel", () => {
expect(container.textContent).toContain("Subtasks content: 2");
});
it("adds related tasks without children and preserves the selected plan", async () => {
fixture.plan = issueDocument("plan", "Plan");
fixture.documents = [fixture.plan];
await render(panel({
showSubtasksTab: true,
tasksTab: { count: 0, content: <div>No tasks yet</div> },
}));
expect(container.querySelector('[data-side-panel-tab-target="subtasks"]')).toBeNull();
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Plan");
await render(panel({
showSubtasksTab: true,
tasksTab: { count: 1, content: <div>Cross-project follow-up</div> },
}));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Plan");
const tasks = container.querySelector<HTMLButtonElement>('[data-side-panel-tab-target="subtasks"]');
expect(tasks?.textContent?.trim()).toBe("Tasks");
await act(async () => tasks?.click());
expect(container.textContent).toContain("Cross-project follow-up");
expect(container.textContent).not.toContain("Subtasks content");
});
it("exposes failed task loading even when no task count is available", async () => {
await render(panel({ showSubtasksTab: true, tasksTab: { count: 0, hasError: true, content: <div role="alert">Could not load all tasks.</div> } }));
const tasks = container.querySelector<HTMLButtonElement>('[data-side-panel-tab-target="subtasks"]');
expect(tasks?.textContent?.trim()).toBe("Tasks");
await act(async () => tasks?.click());
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not load all tasks");
});
it("inserts Subtasks after Properties when child tasks load later", async () => {
await render(panel({ childIssues: [], showSubtasksTab: true, streamlinedTabs: true }));
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-label="Open a new tab"]')?.click());

View File

@ -94,6 +94,8 @@ export interface TaskSidePanelProps {
onRequestClose?: () => void;
streamlinedTabs?: boolean;
showSubtasksTab?: boolean;
/** Optional related-work projection; the host still owns tab layout and state. */
tasksTab?: { count: number; content: ReactNode; hasError?: boolean };
}
const EMPTY_ISSUE_DOCUMENTS: IssueDocument[] = [];
@ -223,6 +225,7 @@ export function TaskSidePanel({
onRequestClose,
streamlinedTabs = false,
showSubtasksTab = false,
tasksTab,
}: TaskSidePanelProps) {
const handleScroll = useScrollbarWhileScrolling();
const viewer = useTaskSidePanelFileRouting();
@ -232,7 +235,9 @@ export function TaskSidePanel({
const restoredRef = useRef(
readTaskSidePanelState(accountScope, issue.companyId, issue.id, fileTabsEnabled),
);
const initialSubtasksAvailableRef = useRef(showSubtasksTab && childIssues.length > 0);
const taskCount = tasksTab?.count ?? childIssues.length;
const taskLabel = tasksTab ? "Tasks" : "Subtasks";
const initialSubtasksAvailableRef = useRef(showSubtasksTab && (taskCount > 0 || tasksTab?.hasError === true));
const subtasksDismissedRef = useRef(
restoredRef.current?.userInteracted === true
&& restoredRef.current.state.tabs.length === 0,
@ -271,7 +276,7 @@ export function TaskSidePanel({
}, [accountScope, issue.companyId, issue.id, launcherOpen]);
const controller = useSidePanelTabs<TaskSidePanelTabPayload>({ initialState, onStateChange: persist });
const activeTab = controller.tabs.find((tab) => tab.id === controller.activeTabId) ?? null;
const subtasksAvailable = showSubtasksTab && childIssues.length > 0;
const subtasksAvailable = showSubtasksTab && (taskCount > 0 || tasksTab?.hasError === true);
const hasSubtasksTab = controller.tabs.some((tab) => tab.id === "subtasks");
useEffect(() => {
@ -452,18 +457,18 @@ export function TaskSidePanel({
return {
id: tab.id,
type: tab.type,
label: document ? documentDisplayTitle(document) : tab.label,
ariaLabel: tab.payload.kind === "subtasks" ? "Subtasks" : tab.ariaLabel,
label: tab.payload.kind === "subtasks" && tasksTab ? "Tasks" : document ? documentDisplayTitle(document) : tab.label,
ariaLabel: tab.payload.kind === "subtasks" ? taskLabel : tab.ariaLabel,
closable: true,
contentMode: tab.contentMode,
icon: tabIcon(tab),
};
}), [controller.tabs, documentByKey]);
}), [controller.tabs, documentByKey, taskCount, taskLabel, tasksTab]);
const launcherSections = useMemo<SidePanelLauncherSection[]>(() => {
const primary: SidePanelLauncherItem[] = [
{ id: "properties", label: "Properties", icon: <SlidersHorizontal />, alreadyOpen: controller.tabs.some((tab) => tab.id === "properties") },
...(subtasksAvailable ? [{ id: "subtasks", label: "Subtasks", description: `${childIssues.length} total`, icon: <ListTree />, alreadyOpen: controller.tabs.some((tab) => tab.id === "subtasks") }] : []),
...(subtasksAvailable ? [{ id: "subtasks", label: taskLabel, description: tasksTab?.hasError ? "Could not load all tasks" : `${taskCount} total`, icon: <ListTree />, alreadyOpen: controller.tabs.some((tab) => tab.id === "subtasks") }] : []),
{ id: "artifacts", label: "Artifacts", icon: <Box />, alreadyOpen: controller.tabs.some((tab) => tab.id === "artifacts") },
];
if (fileTabsEnabled) {
@ -513,7 +518,7 @@ export function TaskSidePanel({
});
}
return sections;
}, [childIssues.length, controller.tabs, documents, fileTabsEnabled, planDocument, recentFilesQuery.data, recentFilesQuery.isError, recentFilesQuery.isLoading, subtasksAvailable]);
}, [taskCount, taskLabel, tasksTab?.hasError, controller.tabs, documents, fileTabsEnabled, planDocument, recentFilesQuery.data, recentFilesQuery.isError, recentFilesQuery.isLoading, subtasksAvailable]);
function selectLauncherItem(item: SidePanelLauncherItem) {
markInteracted();
@ -610,7 +615,7 @@ export function TaskSidePanel({
/>
);
} else if (activeTab.payload.kind === "subtasks") {
content = (
content = tasksTab?.content ?? (
<TaskDetailSubtasksPanel
items={childIssues}
onAddSubtask={onAddSubIssue}

View File

@ -328,6 +328,8 @@ export const queryKeys = {
] as const,
listByParent: (companyId: string, parentId: string) =>
["issues", companyId, "parent", parentId] as const,
listCreatedFromIssue: (companyId: string, issueId: string) =>
["issues", companyId, "created-from", issueId] as const,
listByDescendantRoot: (companyId: string, rootIssueId: string) =>
["issues", companyId, "descendants", rootIssueId] as const,
listByExecutionWorkspace: (

View File

@ -1,3 +1,4 @@
import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel";
import { SavedProviderKeySelect } from "../components/onboarding/SavedProviderKeySelect";
import { RepositoryEditor } from "@/components/RepositoryEditor";
import { TaskChatMarker } from "@/components/task-chat/TaskChatMarker";
@ -2170,6 +2171,26 @@ export function DesignGuide() {
<EnvironmentVariablesEditorShowcase />
</Section>
<Section title="Tasks created from a task">
<SubSection title="Subtasks and created work are independent">
<div className="max-w-xl">
<TaskDetailTasksPanel
subtasks={[DESIGN_GUIDE_TASK]}
createdTasks={[
{ ...DESIGN_GUIDE_TASK, projectId: "design-board", project: { id: "design-board", name: "Board UI" } as Issue["project"] },
{ ...DESIGN_GUIDE_TASK, id: "design-followup", identifier: "PAP-428", title: "Write release notes", status: "todo", projectId: null },
]}
projects={[]}
/>
</div>
</SubSection>
<SubSection title="Empty, loading and failed">
<TaskDetailTasksPanel subtasks={[]} createdTasks={[]} projects={[]} />
<TaskDetailTasksPanel subtasks={[]} createdTasks={[]} projects={[]} isLoading />
<TaskDetailTasksPanel subtasks={[]} createdTasks={[]} projects={[]} hasError onRetry={() => {}} />
</SubSection>
</Section>
<Section title="Execution recovery">
<p className="text-sm text-muted-foreground">
Recovery runs in the background. Task lists keep their ordinary status without

View File

@ -44,6 +44,7 @@ import { ApiError } from "../api/client";
const mockIssuesApi = vi.hoisted(() => ({
get: vi.fn(),
list: vi.fn(),
listAll: vi.fn(),
listAcceptedPlanDecompositions: vi.fn(),
listComments: vi.fn(),
listAttachments: vi.fn(),
@ -1305,6 +1306,7 @@ describe("IssueDetail", () => {
} as Response);
mockIssuesApi.list.mockResolvedValue([]);
mockIssuesApi.listAll.mockImplementation((...args) => mockIssuesApi.list(...args));
mockIssuesApi.listComments.mockResolvedValue([]);
mockIssuesApi.listAttachments.mockResolvedValue([]);
mockIssuesApi.listWorkProducts.mockResolvedValue([]);
@ -2138,6 +2140,32 @@ 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();
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);
mockIssuesApi.list.mockImplementation((_companyId, filters?: { descendantOf?: string; createdFromIssueId?: string }) =>
Promise.resolve(filters?.descendantOf === source.id ? [child] : filters?.createdFromIssueId === source.id ? [created] : []),
);
await act(async () => { root.render(<QueryClientProvider client={queryClient}><IssueDetail /></QueryClientProvider>); });
await flushReact();
await flushReact();
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);
const next = createIssue({ id: "new-created-task", parentId: source.id });
mockIssuesApi.list.mockImplementation((_companyId, filters?: { descendantOf?: string; createdFromIssueId?: string }) =>
Promise.resolve(filters?.descendantOf === source.id ? [child, next] : filters?.createdFromIssueId === source.id ? [created, next] : []),
);
await act(async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(source.companyId) }); });
await flushReact();
expect(taskProjection()?.count).toBe(3);
expect(taskProjection()?.content.props.createdTasks.map((row: Issue) => row.id)).toContain(next.id);
});
it("moves subtask data into the properties panel instead of the chat center pane", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.list.mockResolvedValue([

View File

@ -1,4 +1,5 @@
import type { TaskComposerPause } from "../components/task-chat/TaskChatPausedTakeover";
import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel";
import { TaskChatScrollNavigation } from "@/components/task-chat/scroll-navigation";
import {
memo,
@ -205,7 +206,7 @@ import {
IssueProperties,
type IssuePropertiesDocumentDeepLink,
} from "../components/IssueProperties";
import { TaskSidePanel } from "../components/task-side-panel";
import { TaskSidePanel, type TaskSidePanelProps } from "../components/task-side-panel";
import { SidePanelToggleButton } from "../components/side-panel";
import {
TaskTreeControlDialog,
@ -2819,7 +2820,7 @@ function IssueDetailActivityTab({
);
}
export function IssueDetail() {
export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasksTab"] }) {
const { issueId, companyPrefix } = useParams<{
issueId: string;
companyPrefix: string;
@ -3176,13 +3177,18 @@ export function IssueDetail() {
[issueId, location.state, location.search],
);
const { data: rawChildIssuesData, isLoading: childIssuesLoading } = useQuery({
const {
data: rawChildIssuesData,
isLoading: childIssuesLoading,
isError: childIssuesError,
refetch: refetchChildIssues,
} = useQuery({
queryKey:
issue?.id && resolvedCompanyId
? queryKeys.issues.listByDescendantRoot(resolvedCompanyId, issue.id)
: ["issues", "parent", "pending"],
queryFn: () =>
issuesApi.list(resolvedCompanyId!, {
issuesApi.listAll(resolvedCompanyId!, {
descendantOf: issue!.id,
includeBlockedBy: true,
}),
@ -3192,6 +3198,18 @@ export function IssueDetail() {
),
});
const rawChildIssues: Issue[] = rawChildIssuesData ?? EMPTY_ISSUES;
const createdTasksQuery = useQuery({
queryKey: queryKeys.issues.listCreatedFromIssue(
resolvedCompanyId ?? "pending",
issue?.id ?? "pending",
),
queryFn: () => issuesApi.listAll(resolvedCompanyId!, {
createdFromIssueId: issue!.id,
includeRoutineExecutions: true,
}),
enabled: streamlinedTaskDetailEnabled && !!resolvedCompanyId && !!issue?.id && !tasksTab,
});
const {
data: rawSiblingIssuesData,
isLoading: siblingIssuesLoading,
@ -3443,6 +3461,41 @@ export function IssueDetail() {
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
}, [issue?.id, rawChildIssues]);
const resolvedTasksTab = useMemo(() => {
if (tasksTab) return tasksTab;
if (!streamlinedTaskDetailEnabled) return undefined;
const createdTasks = createdTasksQuery.data ?? EMPTY_ISSUES;
const hasError = createdTasksQuery.isError || childIssuesError;
return {
count: new Set([...childIssues, ...createdTasks].map((task) => task.id)).size,
hasError,
content: (
<TaskDetailTasksPanel
subtasks={childIssues}
createdTasks={createdTasks}
projects={projects ?? []}
isLoading={createdTasksQuery.isLoading || childIssuesLoading}
hasError={hasError}
onRetry={() => {
void createdTasksQuery.refetch();
void refetchChildIssues();
}}
/>
),
};
}, [
tasksTab,
streamlinedTaskDetailEnabled,
childIssues,
childIssuesLoading,
childIssuesError,
refetchChildIssues,
projects,
createdTasksQuery.data,
createdTasksQuery.isError,
createdTasksQuery.isLoading,
createdTasksQuery.refetch,
]);
const liveIssueIds = useMemo(
() =>
collectLiveIssueIds(
@ -5571,6 +5624,7 @@ export function IssueDetail() {
fileTabsEnabled={fileViewerEnabled}
streamlinedTabs={streamlinedTaskDetailEnabled}
showSubtasksTab={streamlinedTaskDetailEnabled}
tasksTab={resolvedTasksTab}
/>
</IssueGalleryContext.Provider>,
{ contentMode: "full-bleed" },
@ -5607,6 +5661,7 @@ export function IssueDetail() {
taskChatShellEnabled,
currentUserId,
fileViewerEnabled,
resolvedTasksTab,
]);
const goToInboxShortcutArmedRef = useRef(false);
@ -8062,6 +8117,7 @@ export function IssueDetail() {
fileTabsEnabled={fileViewerEnabled}
streamlinedTabs={streamlinedTaskDetailEnabled}
showSubtasksTab={streamlinedTaskDetailEnabled}
tasksTab={resolvedTasksTab}
documentDeepLink={
documentDeepLink?.issueId === issue.id
? documentDeepLink

View File

@ -0,0 +1,237 @@
import { useEffect, useMemo, useState } from "react";
import type { Issue, IssueComment } from "@paperclipai/shared";
import { useQueryClient } from "@tanstack/react-query";
import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel";
import { Button } from "@/components/ui/button";
import { queryKeys } from "@/lib/queryKeys";
import { cn, projectRouteRef } from "@/lib/utils";
import { Layout } from "@/components/Layout";
import { IssueDetail } from "@/pages/IssueDetail";
import { ProjectDetail } from "@/pages/ProjectDetail";
import { Navigate, Route, Routes, useParams } from "@/lib/router";
import { seedIssueDetailCache } from "@/lib/issueDetailCache";
import { taskPanelPropertiesTab, taskPanelSubtasksTab, taskPanelDocumentTab, taskPanelArtifactsTab, writeTaskSidePanelState } from "@/lib/task-side-panel-state";
import { createIssue, storybookAgents, storybookCompanies, storybookAuthSession, storybookProjects, storybookIssueDocuments } from "../../fixtures/paperclipData";
// PAP-1953's phase topology is already documented in sub-issues-workflow.stories.
// These are review fixtures, not a claim about the live task's current state.
export const sourceTask = createIssue({
id: "origin-story-source", identifier: "PAP-1953", issueNumber: 1953,
title: "Ship the next phase of the board UI",
status: "in_review",
executionWorkspaceId: null, currentExecutionWorkspace: null, projectWorkspaceId: null,
description: "Finish the board UI rollout. Verify each phase and capture any follow-up work in the project where it belongs.",
checkoutRunId: null, executionRunId: null, executionLockedAt: null,
labelIds: [], labels: [], workProducts: [],
createdAt: new Date("2026-09-11T13:00:00Z"), updatedAt: new Date("2026-09-11T14:00:00Z"),
});
const runSources = new Map([
["origin-run-legacy", sourceTask.id],
["origin-run-native", sourceTask.id],
["origin-run-unrelated", "another-source"],
]);
function task(number: number, title: string, status: Issue["status"], parentId: string | null, originRunId = "origin-run-native", projectIndex = 0): Issue {
const project = storybookProjects[projectIndex]!;
return createIssue({
id: `origin-story-${number}`, identifier: `PAP-${number}`, issueNumber: number,
title, status, parentId, originRunId,
projectId: project.id, project, projectWorkspaceId: null,
executionWorkspaceId: null, currentExecutionWorkspace: null,
createdByAgentId: "agent-codex", createdByUserId: null,
assigneeAgentId: number % 2 === 0 ? "agent-codex" : "agent-qa",
checkoutRunId: null, executionRunId: null, executionLockedAt: null,
labelIds: [], labels: [], blockedBy: [], blocks: [],
createdAt: new Date("2026-09-11T13:00:00Z"), updatedAt: new Date("2026-09-11T14:00:00Z"),
lastActivityAt: new Date("2026-09-11T14:00:00Z"), lastExternalCommentAt: null, myLastTouchAt: null, isUnreadForMe: false,
completedAt: status === "done" ? new Date("2026-09-11T14:00:00Z") : null,
cancelledAt: status === "cancelled" ? new Date("2026-09-11T14:00:00Z") : null,
});
}
export const taskCandidates = [
task(1954, "Scoping review", "done", sourceTask.id, "origin-run-legacy"),
task(1964, "Phase 5 — UI polish", "in_progress", sourceTask.id),
task(2189, "Keep task list filters when switching projects", "todo", null, "origin-run-legacy", 1),
task(1965, "Phase 6 — release verification", "blocked", sourceTask.id),
task(2190, "Document the new task navigation", "in_review", "docs-parent", "origin-run-native", 1),
task(1963, "Phase 4 — API surface", "done", sourceTask.id, "origin-run-legacy"),
task(2191, "Replace the legacy filter popover", "cancelled", null),
task(2192, "Investigate an unrelated runner timeout", "todo", null, "origin-run-unrelated"),
{ ...task(2193, "Follow up on release notes", "todo", null), projectId: null, project: null },
task(2194, "Review accessibility", "todo", sourceTask.id, "origin-run-unrelated"),
];
// Model the proposed API projection explicitly. Parentage and creation are
// independent. Deduplicate children created by the source run; never include
// unrelated tasks merely because the same agent created them.
export function tasksForSource(candidates: Issue[]): Issue[] {
return [...new Map(candidates.filter((item) =>
item.companyId === sourceTask.companyId && !item.hiddenAt && item.id !== sourceTask.id &&
(item.parentId === sourceTask.id || (item.originRunId && runSources.get(item.originRunId) === sourceTask.id)),
).map((item) => [item.id, item])).values()];
}
export type Scenario = "mixed" | "subtasks" | "other" | "completed" | "empty" | "arrival";
export function scenarioTasks(scenario: Scenario) {
const items = tasksForSource(taskCandidates);
if (scenario === "empty" || scenario === "arrival") return [];
if (scenario === "subtasks") return items.filter((item) => item.parentId === sourceTask.id);
if (scenario === "other") return items.filter((item) => item.parentId !== sourceTask.id);
if (scenario === "completed") return items.map((item) => ({ ...item, status: item.status === "cancelled" ? "cancelled" as const : "done" as const }));
return items;
}
export function SeedData({ children }: { children: React.ReactNode }) {
const client = useQueryClient();
const [ready] = useState(() => {
client.setQueryData(queryKeys.companies.all, { companies: storybookCompanies, unauthorized: false });
client.setQueryData(queryKeys.auth.session, storybookAuthSession);
client.setQueryData(queryKeys.agents.list(sourceTask.companyId), storybookAgents);
client.setQueryData(queryKeys.projects.list(sourceTask.companyId), storybookProjects);
client.setQueryData(queryKeys.issues.list(sourceTask.companyId), [sourceTask, ...taskCandidates]);
client.setQueryData(queryKeys.issues.labels(sourceTask.companyId), []);
client.setQueryData(queryKeys.instance.experimentalSettings, { enableStreamlinedUi: true, enableIsolatedWorkspaces: false });
client.setQueryData(queryKeys.issues.documents(sourceTask.id), []);
client.setQueryData(queryKeys.issues.runs(sourceTask.id), []);
client.setQueryData(queryKeys.issues.liveRuns(sourceTask.id), []);
client.setQueryData(queryKeys.issues.activeRun(sourceTask.id), null);
return true;
});
return ready ? children : null;
}
export function TasksPanel({ items }: { items: Issue[] }) {
return <TaskDetailTasksPanel
subtasks={items.filter((item) => item.parentId === sourceTask.id)}
createdTasks={items.filter((item) => item.originRunId && runSources.get(item.originRunId) === sourceTask.id)}
projects={storybookProjects}
/>;
}
const plan = "## Rollout plan\n\n1. Complete scoping and the API surface.\n2. Finish UI polish and verify the release.\n3. Track filter persistence and navigation documentation in their owning projects.\n\n### Acceptance\n\nThe board can inspect every task created during this work, including tasks outside this hierarchy.";
const baseComments: IssueComment[] = [
{ id: "origin-comment-1", companyId: sourceTask.companyId, issueId: sourceTask.id, authorAgentId: null, authorUserId: "user-board", authorType: "user", body: sourceTask.description!, presentation: null, metadata: null, createdAt: new Date("2026-09-11T13:00:00Z"), updatedAt: new Date("2026-09-11T13:00:00Z") },
{ id: "origin-comment-2", companyId: sourceTask.companyId, issueId: sourceTask.id, authorAgentId: "agent-codex", authorUserId: null, authorType: "agent", body: "Scoping and the API surface are complete. UI polish is in progress; release verification is waiting on it.\n\nI also found two follow-ups: filter persistence and navigation docs. I created those in their owning projects so we can track them without changing the rollout hierarchy.", presentation: null, metadata: null, createdAt: new Date("2026-09-11T14:00:00Z"), updatedAt: new Date("2026-09-11T14:00:00Z") },
];
const planDocument = { ...storybookIssueDocuments[0]!, issueId: sourceTask.id, body: plan };
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 }) {
const client = useQueryClient();
const [fixture] = useState(() => {
const rows = [sourceTask, ...taskCandidates];
const comments = scenario === "arrival" ? [] : [baseComments[1]!];
for (const row of rows) {
seedIssueDetailCache(client, row);
for (const ref of [row.id, row.identifier!]) {
client.setQueryData(queryKeys.issues.comments(ref), { pages: [row.id === sourceTask.id ? [...comments].reverse() : []], pageParams: [null] });
client.setQueryData(queryKeys.issues.documents(ref), row.id === sourceTask.id ? [planDocument] : []);
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(queryKeys.health, { status: "ok", deploymentMode: "local_trusted", bootstrapStatus: "ready" });
client.setQueryData(queryKeys.instance.generalSettings, { keyboardShortcuts: true });
client.setQueryData(queryKeys.access.currentBoardAccess, { companyIds: [] });
client.setQueryData(queryKeys.issues.listCreatedFromIssue(sourceTask.companyId, sourceTask.id), taskCandidates.filter((row) => row.originRunId && runSources.get(row.originRunId) === sourceTask.id));
client.setQueryData(queryKeys.issues.listByDescendantRoot(sourceTask.companyId, sourceTask.id), taskCandidates.filter((row) => row.parentId === sourceTask.id));
const userId = storybookAuthSession.user.id;
writeTaskSidePanelState(userId, sourceTask.companyId, sourceTask.id, {
state: {
tabs: [taskPanelPropertiesTab(), ...(scenario !== "arrival" ? [taskPanelSubtasksTab()] : []), taskPanelDocumentTab("plan", "Plan"), taskPanelArtifactsTab()],
activeTabId: scenario === "arrival" ? "document:plan" : "subtasks",
},
userInteracted: true, autoPlanHandled: true, launcherOpen: false, updatedAt: Date.now(),
});
const originalFetch = window.fetch;
const fetchFixture: typeof fetch = async (input, init) => {
const raw = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const url = new URL(raw, window.location.origin);
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
if (url.pathname === "/api/health") return Response.json({ status: "ok", deploymentMode: "local_trusted", bootstrapStatus: "ready" });
if (url.pathname === "/api/instance/settings/general") return Response.json({ keyboardShortcuts: true });
const projectMatch = url.pathname.match(/^\/api\/projects\/([^/]+)$/);
if (projectMatch && method === "GET") {
const project = storybookProjects.find((item) => item.id === projectMatch[1] || projectRouteRef(item) === projectMatch[1]);
return project ? Response.json(project) : Response.json({ error: "Project is outside this story" }, { status: 404 });
}
const match = url.pathname.match(/^\/api\/issues\/([^/]+)(?:\/(.*))?$/);
if (match) {
const row = rows.find((item) => item.id === match[1] || item.identifier === match[1]);
if (!row) return Response.json({ error: "Task is outside this story" }, { status: 404 });
const resource = match[2] ?? "";
if (!resource) {
if (method === "PATCH") Object.assign(row, JSON.parse(String(init?.body ?? "{}")));
return Response.json(row);
}
if (resource === "comments") {
if (method === "POST") {
const body = JSON.parse(String(init?.body ?? "{}"));
const comment = { ...baseComments[0]!, ...body, id: `story-comment-${comments.length}`, issueId: row.id, createdAt: new Date(), updatedAt: new Date() };
comments.push(comment);
return Response.json(comment);
}
return Response.json(row.id === sourceTask.id ? [...comments].reverse() : []);
}
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 === "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([]);
return Response.json({ error: `Not simulated: ${resource}` }, { status: 404 });
}
if (url.pathname === `/api/companies/${sourceTask.companyId}/issues`) {
const parent = url.searchParams.get("descendantOf") ?? url.searchParams.get("parentId");
const projectId = url.searchParams.get("projectId");
const createdFrom = url.searchParams.get("createdFromIssueId");
const filtered = rows.filter((row) => (!parent || row.parentId === parent) && (!projectId || row.projectId === projectId) && (!createdFrom || (row.originRunId && runSources.get(row.originRunId) === createdFrom)));
const afterId = url.searchParams.get("afterId");
const ordered = url.searchParams.get("sortField") === "id"
? filtered.sort((a, b) => a.id.localeCompare(b.id)).filter((row) => !afterId || row.id > afterId)
: filtered;
const offset = Number(url.searchParams.get("offset") ?? 0);
const limit = Number(url.searchParams.get("limit") ?? filtered.length);
return Response.json(ordered.slice(offset, offset + limit));
}
return originalFetch(input, init);
};
window.fetch = fetchFixture;
return { restore: () => { if (window.fetch === fetchFixture) window.fetch = originalFetch; } };
});
useEffect(() => fixture.restore, [fixture]);
return children;
}
function TaskRoute({ tasksTab }: { tasksTab?: React.ComponentProps<typeof IssueDetail>["tasksTab"] }) {
const { issueId } = useParams();
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 }) {
const [items, setItems] = useState(() => scenarioTasks(scenario));
const tasksTab = useMemo(() => ({
count: items.length,
content: <TasksPanel items={items} />,
}), [items]);
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}>
{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 />}>
<Route path="issues/:issueId" element={<TaskRoute tasksTab={baseline || scenario === "mixed" ? undefined : tasksTab} />} />
<Route path="projects/:projectId/*" element={<ProjectDetail />} />
</Route>
<Route path="*" element={<Navigate to={`/PAP/issues/${sourceTask.identifier}`} replace />} />
</Routes>
</TaskPageData>
);
}

View File

@ -0,0 +1,42 @@
# Tasks created from a task
Run `pnpm --filter @paperclipai/ui exec storybook dev -p 6017 -c storybook/.storybook --no-open`.
Open **UX Labs → Tasks Created From a Task → Full Task Page**.
The stories render production `Layout`, `IssueDetail`, `TaskSidePanel` and
`TaskDetailTasksPanel` with illustrative fixtures based on PAP-1953. They use no
replica shell or custom CSS. Task and project links navigate to real page
components backed by local fixtures.
## Membership
- 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
both sections. Deduplication applies within each section, not between them.
- Fixtures cover both legacy and native runs, a subtask created elsewhere, an
unrelated task by the same agent, and a created task without a project.
- Unfinished tasks sort before done and cancelled tasks. Only Subtasks shows a
progress bar. Empty Subtasks sections are omitted.
- Sections fold independently. Carets follow the heading text; project links sit
at the far right. Controls fade on hover or focus using reduced-motion-aware
tokens. There are no project icons, tab counts, search or collection controls.
- First Task Appears demonstrates the tab arriving without replacing the Plan tab.
## Production integration
`GET /api/companies/:companyId/issues?createdFromIssueId=<uuid>` projects creation
provenance through `issues.originRunId` and the originating heartbeat run. Native
runs use `nativeIssueId`; legacy/historical runs fall back to the persisted
`issueId`, `taskId`, or `taskKey` context. The source, run and results must belong
to the requested company. When the origin run is absent, recorded issue creation activity can recover its
run. Comments and shared creators are never used to infer attribution.
The normal issue creation service persists the actor run when no explicit origin
run was provided, including the legacy child helper. Native creation already
provides its run. No schema migration or runner-mode distinction is needed.
The real issue page queries this projection alongside its existing subtask query.
Both queries page by immutable task ID with an afterId cursor so task activity
and removal of earlier rows do not shift later pages. Existing company issue-list invalidation refreshes both on task activity. Errors
remain visible with a Retry action. Historical rows without either an origin run or attributed creation activity
cannot be attributed and are not included in project groups.

View File

@ -0,0 +1,27 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PluginLauncherProvider } from "@/plugins/launchers";
import { OriginatingTasksReview, SeedData } from "../prototypes/originating-tasks/OriginatingTasks";
const meta = {
title: "UX Labs/Tasks Created From a Task",
component: OriginatingTasksReview,
parameters: {
layout: "fullscreen",
docs: { description: { component: "UX prototype based on the existing PAP-1953 phase-topology fixture, with illustrative cross-project follow-ups. Renders the actual Layout and IssueDetail route, with the production TaskSidePanel. No replica page shell or CSS overrides. Full Task Page uses the production query wiring with fixture API responses. Both legacy and native run origins are included. Subtasks includes all subtasks; created work is grouped independently by project, so a created subtask appears in both sections. An unrelated task by the same agent is deliberately excluded. Subtasks reuse TaskDetailSubtasksPanel and its progress bar. All created work uses the same task rows, grouped by project or No project, without progress bars. Unfinished work precedes finished work. No counts on the Tasks tab, extra header, search, controls, or relationship tabs. Task navigation, local chat and side-panel tabs remain interactive. The story uses the production Tasks panel with fixture data." } },
},
args: { scenario: "mixed", fullPage: true, narrow: false },
decorators: [(Story, context) => <SeedData key={context.id}><PluginLauncherProvider><Story /></PluginLauncherProvider></SeedData>],
} satisfies Meta<typeof OriginatingTasksReview>;
export default meta;
type Story = StoryObj<typeof meta>;
export const FullTaskPage: Story = {};
export const ImplementedTaskPage: Story = { args: { baseline: true } };
export const FirstTaskAppears: Story = { args: { scenario: "arrival" } };
export const MixedTasksPanel: Story = { args: { fullPage: false } };
export const SubtasksOnly: Story = { args: { fullPage: false, scenario: "subtasks" } };
export const OtherProjectsOnly: Story = { args: { fullPage: false, scenario: "other" } };
export const AllFinished: Story = { args: { fullPage: false, scenario: "completed" } };
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" } };