diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 4e62dfd731..b3bfaba774 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -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=` 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=` 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. diff --git a/doc/SPEC.md b/doc/SPEC.md index 2880a62e05..e3af9a9961 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -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 diff --git a/server/src/__tests__/issue-created-from-routes.test.ts b/server/src/__tests__/issue-created-from-routes.test.ts new file mode 100644 index 0000000000..c694460a3c --- /dev/null +++ b/server/src/__tests__/issue-created-from-routes.test.ts @@ -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; + let database: Awaited>; + 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(); + + 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); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 3f7905745d..e587b51a2e 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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, diff --git a/server/src/services/issue-creation-origin.ts b/server/src/services/issue-creation-origin.ts new file mode 100644 index 0000000000..2f71ee6409 --- /dev/null +++ b/server/src/services/issue-creation-origin.ts @@ -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` + ${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}) + )) + ) + `; +} diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index e51af10035..1197e3fbae 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -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` ${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` ${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", diff --git a/ui/src/api/issues.test.ts b/ui/src/api/issues.test.ts index 2eee55a324..3d78406b57 100644 --- a/ui/src/api/issues.test.ts +++ b/ui/src/api/issues.test.ts @@ -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", diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index ad6af59f84..26110545d8 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -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, options?: RequestOptions): Promise => { + const pageSize = 500; + const tasks = new Map(); + 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, diff --git a/ui/src/components/task-detail/TaskDetailRelationsPanel.tsx b/ui/src/components/task-detail/TaskDetailRelationsPanel.tsx index 099e27f948..1530482a04 100644 --- a/ui/src/components/task-detail/TaskDetailRelationsPanel.tsx +++ b/ui/src/components/task-detail/TaskDetailRelationsPanel.tsx @@ -86,7 +86,7 @@ export function resolveTaskDetailSubtaskState(items: Issue[]) { }; } -function SharedSubtaskList({ +export function TaskDetailTaskList({ items, ariaLabel, issueLinkState, @@ -166,7 +166,7 @@ export function TaskDetailSubtasksPanel({

{nextAction.status === "blocked" ? "Blocked subtask" : "Next action"}

- {nextAction ? "Other subtasks" : "Subtasks"} - ({ Link: ({ to, children, ...props }: { to: string; children: React.ReactNode }) => {children} })); +vi.mock("@/components/IssueRow", () => ({ IssueRow: ({ issue }: { issue: Issue }) => {issue.title} })); +const task = (id: string, overrides: Partial = {}) => ({ 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; +afterEach(() => { act(() => root?.unmount()); container?.remove(); }); +function render(props: React.ComponentProps) { + container = document.createElement("div"); document.body.append(container); + root = createRoot(container); + act(() => root.render()); +} + +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(); + }); +}); diff --git a/ui/src/components/task-detail/TaskDetailTasksPanel.tsx b/ui/src/components/task-detail/TaskDetailTasksPanel.tsx new file mode 100644 index 0000000000..0a90ddca77 --- /dev/null +++ b/ui/src/components/task-detail/TaskDetailTasksPanel.tsx @@ -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 ( + +
+
+

+ + {name} + + + + +

+ {projectPath && ( + + + + )} +
+ {children} +
+
+ ); +} + +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(); + 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 ( +
+ {sortedSubtasks.length > 0 && ( + + + + )} + {[...groups.entries()].sort(([, a], [, b]) => a.name.localeCompare(b.name)).map(([id, group]) => ( + + + + ))} + {isLoading &&

Loading tasks…

} + {hasError && ( +
+ Could not load all tasks. + {onRetry && } +
+ )} + {!isLoading && !hasError && subtasks.length === 0 && createdTasks.length === 0 && ( +

No tasks yet.

+ )} +
+ ); +} + +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), + ); +} diff --git a/ui/src/components/task-side-panel/TaskSidePanel.test.tsx b/ui/src/components/task-side-panel/TaskSidePanel.test.tsx index fc22194e35..f5b292a365 100644 --- a/ui/src/components/task-side-panel/TaskSidePanel.test.tsx +++ b/ui/src/components/task-side-panel/TaskSidePanel.test.tsx @@ -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:
No tasks yet
}, + })); + 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:
Cross-project follow-up
}, + })); + expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Plan"); + const tasks = container.querySelector('[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:
Could not load all tasks.
} })); + const tasks = container.querySelector('[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('button[aria-label="Open a new tab"]')?.click()); diff --git a/ui/src/components/task-side-panel/TaskSidePanel.tsx b/ui/src/components/task-side-panel/TaskSidePanel.tsx index da1640abe2..b364c68f92 100644 --- a/ui/src/components/task-side-panel/TaskSidePanel.tsx +++ b/ui/src/components/task-side-panel/TaskSidePanel.tsx @@ -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({ 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(() => { const primary: SidePanelLauncherItem[] = [ { id: "properties", label: "Properties", icon: , alreadyOpen: controller.tabs.some((tab) => tab.id === "properties") }, - ...(subtasksAvailable ? [{ id: "subtasks", label: "Subtasks", description: `${childIssues.length} total`, icon: , alreadyOpen: controller.tabs.some((tab) => tab.id === "subtasks") }] : []), + ...(subtasksAvailable ? [{ id: "subtasks", label: taskLabel, description: tasksTab?.hasError ? "Could not load all tasks" : `${taskCount} total`, icon: , alreadyOpen: controller.tabs.some((tab) => tab.id === "subtasks") }] : []), { id: "artifacts", label: "Artifacts", icon: , 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 ?? ( ["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: ( diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index f7c0fafdaa..201230bc03 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -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() { +
+ +
+ +
+
+ + + + {}} /> + +
+

Recovery runs in the background. Task lists keep their ordinary status without diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 2046dd044a..36ea52e93e 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -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(); }); + 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([ diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 83bf61661c..ffce188be9 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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: ( + { + 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} /> , { 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 diff --git a/ui/storybook/prototypes/originating-tasks/OriginatingTasks.tsx b/ui/storybook/prototypes/originating-tasks/OriginatingTasks.tsx new file mode 100644 index 0000000000..9319598ab1 --- /dev/null +++ b/ui/storybook/prototypes/originating-tasks/OriginatingTasks.tsx @@ -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 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["tasksTab"] }) { + const { issueId } = useParams(); + return ; +} + +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: , + }), [items]); + if (!fullPage) return

{tasksTab.content}
; + return ( + + {scenario === "arrival" &&
Story control
} + + }> + } /> + } /> + + } /> + +
+ ); +} diff --git a/ui/storybook/prototypes/originating-tasks/README.md b/ui/storybook/prototypes/originating-tasks/README.md new file mode 100644 index 0000000000..8922801c0f --- /dev/null +++ b/ui/storybook/prototypes/originating-tasks/README.md @@ -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=` 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. diff --git a/ui/storybook/stories/originating-tasks.stories.tsx b/ui/storybook/stories/originating-tasks.stories.tsx new file mode 100644 index 0000000000..4ac6d90512 --- /dev/null +++ b/ui/storybook/stories/originating-tasks.stories.tsx @@ -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) => ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +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" } };