From 909e29dd071b1aeeb24c2933af049da90a6066d3 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:42:24 -0500 Subject: [PATCH] fix(ui): show agent-created subtasks inline in the task feed (#18320) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The task feed shows agent messages, documents, and artifacts. > - An agent can create a subtask or a separate follow-up task during that work. > - The reader must open the Tasks tab to find the new task. > - This pull request adds a linked task card to the source feed at creation time. > - The card shows the current title, task number, and status. > - The reader can inspect delegated work without searching a second panel. ## Linked Issues or Issue Description **What existing behavior does this improve?** Task creation visibility in the source task feed. Related work: #13823 added ancestor navigation in the Tasks panel. **Current behavior** Agent-created child tasks appear in the Tasks tab. The main feed has no persistent link card for those tasks. **Proposed behavior** Show each agent-created direct child or source-linked follow-up task once in the feed. Keep the card at creation time when status changes. Keep existing chat handoff and accepted suggestion cards without duplicates. **Reason and benefit** Readers can see which work an agent created and open that task from the conversation. **Breaking changes** None. This change uses existing authorized task reads. It does not add API or database fields. ## What Changed - Pass the existing child and source-task reads from task detail to the feed. - Add a task card with a link, wrapping title, task number, and status badge. Use existing design tokens. - Suppress repeated cards and tasks already shown by accepted suggestions or chat handoffs. - Show task-read failures with Retry. Keep the conversation readable during those failures. - Add feed and page regression cases plus desktop, multiple-task, dark, and mobile stories. Update the product description and delegation recipe. - Keep task selection and rendering in the task feed module. Existing issue APIs continue to own access checks and company scope. ## Verification - Passed: 224 focused tests with `pnpm exec vitest run ui/src/components/TaskChatThread.task-creation-order.test.tsx ui/src/pages/IssueDetail.test.tsx ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx --maxWorkers=1`. - Passed: `pnpm --filter @paperclipai/ui typecheck`. - Passed: `pnpm check:token-gates`, `node scripts/check-maps.mjs`, and `node scripts/storybook-catalog.mjs`. - Cases cover creation order, current status, links, company filtering, direct children versus grandchildren, duplicate suppression, and read failure with retry. - Passed: browser render and link assertions for desktop, multiple tasks, dark theme, and mobile at 390px. All four story play phases finished. No page errors or horizontal overflow occurred. Screenshots use fixture task data and the production task feed. They do not prove server persistence or agent execution. - Passed: `pnpm --filter @paperclipai/ui build` after the concurrent memory failure. - The mobile preview frame needed spacing to contain the feed scrollbar gutter. Fixed the frame and repeated the browser checks. - Addressed the first Greptile finding: source-linked follow-ups now load with Streamlined UI off. A regression test covers that setting. - Full local build and typecheck cannot complete because Cargo is absent. The first concurrent UI build and UI typecheck were killed by the workspace memory limit. Both UI commands passed when run separately. The full local test run was stopped to fit that limit. PR CI must supply the full-suite verdict. ## Risks - Low risk. Delayed task reads can insert an older card into a loaded feed. Stable task IDs preserve the card during status updates. - New cards use the same access-checked task data as the Tasks panel. No task mutation or permission change is added. ## Model Used - OpenAI Codex, GPT-6. The runtime does not expose a more specific model ID or context size. Used reasoning, tool calls, code edits, tests, and browser automation. ## 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 - [ ] All Paperclip CI gates are green - [ ] 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 --- doc/PRODUCT.md | 4 + maps/feature-map/task-coordination.md | 10 +++ ...askChatThread.task-creation-order.test.tsx | 46 ++++++++++- ui/src/components/TaskChatThread.tsx | 16 +++- .../task-chat/TaskChatTaskCreatedCard.tsx | 27 +++++++ .../task-chat/TaskChatThreadView.tsx | 2 + .../components/task-chat/task-chat-model.ts | 10 +++ .../task-chat/task-created-items.ts | 23 ++++++ ui/src/pages/IssueDetail.test.tsx | 38 +++++++++ ui/src/pages/IssueDetail.tsx | 25 +++++- .../task-created-inline.stories.tsx | 79 +++++++++++++++++++ 11 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 ui/src/components/task-chat/TaskChatTaskCreatedCard.tsx create mode 100644 ui/src/components/task-chat/task-created-items.ts create mode 100644 ui/storybook/stories/task-coordination/task-created-inline.stories.tsx diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index 9d5190b1cee..cba3bec4ae2 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -93,6 +93,10 @@ Tasks have parentage. Every task exists in service of a parent task, all the way The current issue model includes stable issue identifiers, parent/sub-issues, blockers, a single assignee, comments, issue documents, attachments and work products, and review/approval handoffs. That structure keeps work inspectable by both the board and agents while still allowing agents to decompose work into smaller tasks. +When an agent creates a subtask or follow-up task, its source task's feed shows +a linked card at creation time with the current title, task number, and status. +The Tasks tab keeps the full relationship list. + Automatic monitor checks pause when a task is blocked. A person can use Check now to request one check of a retained monitor through normal execution admission. Dependencies, recovery, pause, budgets and ownership still apply. Provider-usage diff --git a/maps/feature-map/task-coordination.md b/maps/feature-map/task-coordination.md index 950219c3292..0d6a7e7c1b2 100644 --- a/maps/feature-map/task-coordination.md +++ b/maps/feature-map/task-coordination.md @@ -25,6 +25,11 @@ Agent access: [companion](../agent-access-map/task-coordination.md). Open the task’s parent/child and dependency links in task detail. +The task feed shows a linked card when an agent creates a direct subtask or a +follow-up task from that task. The card stays at its creation time beside +documents and artifacts. It shows the current task title, number, and status. +The Tasks tab retains the complete relationship list. + ### `review-and-controls` Inspect the task’s execution policy and available review, pause, resume, or stop controls. @@ -41,6 +46,11 @@ creates a hold; it does not change task statuses to a paused status. Automated: [dependency wakeups](../../server/src/__tests__/issue-dependency-wakeups-routes.test.ts) checks server behavior; visual relationship navigation remains a manual check. +The [created task card stories](../../ui/storybook/stories/task-coordination/task-created-inline.stories.tsx) +render the production feed with fixture task reads. The +[task feed tests](../../ui/src/components/TaskChatThread.task-creation-order.test.tsx) +check creation order, current status, links, and duplicate suppression. + Manual: Block one task on another, finish the prerequisite, and observe the dependent task’s eligible continuation. Follow parent/child links back to the source request. Verify no duplicate run or false parent completion occurs. ### `review-and-controls` diff --git a/ui/src/components/TaskChatThread.task-creation-order.test.tsx b/ui/src/components/TaskChatThread.task-creation-order.test.tsx index ef5498acd11..c81a4ddfda9 100644 --- a/ui/src/components/TaskChatThread.task-creation-order.test.tsx +++ b/ui/src/components/TaskChatThread.task-creation-order.test.tsx @@ -3,7 +3,7 @@ import { act, forwardRef, useImperativeHandle, type ForwardedRef } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ConversationTaskHandoff, IssueDocument, IssueThreadInteraction } from "@paperclipai/shared"; +import type { ConversationTaskHandoff, Issue, IssueDocument, IssueThreadInteraction } from "@paperclipai/shared"; import { TaskChatThread } from "./TaskChatThread"; import { CoordinatorPresentation } from "./coordinator/CoordinatorPresentation"; import { ThemeProvider } from "@/context/ThemeContext"; @@ -77,6 +77,10 @@ const handoff = (id = "task", sourceRunId: string | null = "source-run", second id: `chat_handoff:${id}`, issueId: id, identifier: `PAPER-${id}`, title: `Created ${id}`, status: "todo", createdAt: at(second), sourceRunId, suggestionInteractionId: null, }); +const createdTask = (overrides: Partial = {}): Issue => ({ + id: "child", companyId: "company", parentId: "chat", identifier: "PAPER-102", title: "Verify the repair", + status: "todo", createdByAgentId: "agent", createdAt: new Date(at(5)), ...overrides, +} as Issue); const suggestion = (status: "pending" | "accepted"): IssueThreadInteraction => ({ ...pendingSuggestedTasksInteraction, id: "suggestion", companyId: "company", issueId: "chat", sourceRunId: "source-run", status, createdAt: new Date(at(3)), updatedAt: new Date(at(3)), @@ -111,6 +115,46 @@ const anchor = (id: string) => host.querySelector(`[data-thread-anchor="${id}"]` const before = (earlier: Element, later: Element) => expect(earlier.compareDocumentPosition(later) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); describe("Coordinator task creation ordering", () => { + it("shows agent-created subtasks in ordinary task feeds at creation time and refreshes their status", async () => { + state.coordinator = false; + await paint({ conversationMode: false, companyId: "company", createdTasks: [createdTask(), createdTask()] }); + const card = host.querySelector('[data-testid="task-chat-created-task"]')!; + expect(card.textContent).toContain("Subtask created"); + expect(card.textContent).toContain("Verify the repair"); + expect(card.textContent).toContain("Todo"); + expect(card.getAttribute("href")).toBe("/issues/PAPER-102"); + before(card, anchor("final")); + before(anchor("final"), anchor("next")); + expect(host.querySelectorAll('[data-testid="task-chat-created-task"]')).toHaveLength(1); + await paint({ conversationMode: false, companyId: "company", createdTasks: [createdTask({ status: "done" })] }); + expect(host.querySelector('[data-testid="task-chat-created-task"]')).toBe(card); + expect(card.textContent).toContain("Done"); + }); + + it("filters human and foreign-company tasks and links standalone follow-ups by ID", async () => { + state.coordinator = false; + await paint({ conversationMode: false, companyId: "company", createdTasks: [ + createdTask({ id: "human", createdByAgentId: null }), + createdTask({ id: "foreign", companyId: "other" }), + createdTask({ id: "chat" }), + createdTask({ parentId: null, identifier: null }), + ] }); + const cards = host.querySelectorAll('[data-testid="task-chat-created-task"]'); + expect(cards).toHaveLength(1); + expect(cards[0].textContent).toContain("Task created"); + expect(cards[0].getAttribute("href")).toBe("/issues/child"); + }); + + it("does not duplicate tasks already shown by chat handoffs or accepted suggestions", async () => { + state.handoffs = [handoff("direct")]; + await paint({ companyId: "company", interactions: [suggestion("accepted")], createdTasks: [ + createdTask({ id: "direct" }), createdTask({ id: "suggested-task" }), createdTask(), + ] }); + expect(host.querySelectorAll('[data-testid="task-chat-created-task"]')).toHaveLength(1); + expect(anchor("chat_handoff:direct")).toBeTruthy(); + expect(anchor("interaction:suggestion").querySelector(".coordinator-created-task")).toBeTruthy(); + }); + it("keeps the saved plan before its resulting document even when both belong to one native run", async () => { state.plan = { id: "plan", issueId: "chat", companyId: "company", key: "plan", title: "Plan", body: "Draft the concepts.", format: "markdown", latestRevisionId: "plan-rev", latestRevisionNumber: 1, diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index e68b2fbcaa7..e89adcb5a54 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -10,9 +10,10 @@ import { import { hasWorkspaceRestoreFailure } from "@paperclipai/shared"; import { workspaceRestoreMarkerDetail } from "@/lib/workspace-restore-marker"; import { credentialAccessNotice } from "@/lib/credential-access-notice"; -import type { ActivityEvent, IssueQueuedCommentQueue, TaskBrowser } from "@paperclipai/shared"; +import type { ActivityEvent, Issue, IssueQueuedCommentQueue, TaskBrowser } from "@paperclipai/shared"; import { useProjectCreatedItems } from "@/hooks/useProjectCreatedItems"; import { skillCreatedItems } from "@/components/task-chat/skill-created-items"; +import { taskCreatedItems } from "@/components/task-chat/task-created-items"; import { useCoordinatorPresentation } from "@/components/coordinator/CoordinatorPresentation"; import { CoordinatorChatHandoffStatus, CoordinatorChatTaskHandoff, standaloneChatHandoffs, useCoordinatorChatTaskHandoffs, useLiveChatHandoffIds } from "@/components/coordinator/CoordinatorChatTaskHandoffs"; import { CoordinatorChatReceiptStatus, useCoordinatorChatDecisionReceipts } from "@/components/coordinator/CoordinatorChatDecisionReceipts"; @@ -429,6 +430,7 @@ function resolvedWithoutUserFacingResponse(value: unknown): boolean { export type TaskChatThreadProps = ComponentProps & { conversationMode?: boolean; creationActivity?: ActivityEvent[]; + createdTasks?: Issue[]; compactHistory?: boolean; scrollingHistory?: ScrollingHistory; historyControl?: ReactNode; @@ -636,6 +638,14 @@ export function TaskChatThread(props: TaskChatThreadProps) { const liveHandoffIds = useLiveChatHandoffIds(`${currentUserId}:${issueId}`, taskHandoffs.handoffs, taskHandoffs.isSuccess); const createdProjectItems = useProjectCreatedItems(props.creationActivity ?? [], companyId); const createdSkillItems = useMemo(() => skillCreatedItems(props.creationActivity ?? []), [props.creationActivity]); + const createdTaskItems = useMemo(() => { + const representedTaskIds = new Set(taskHandoffs.handoffs.map((handoff) => handoff.issueId)); + for (const interaction of interactions ?? []) { + if (interaction.kind !== "suggest_tasks" || interaction.status !== "accepted" || isSuppressedThreadInteraction(interaction)) continue; + for (const task of interaction.result?.createdTasks ?? []) representedTaskIds.add(task.issueId); + } + return taskCreatedItems(props.createdTasks ?? [], issueId, companyId, representedTaskIds); + }, [props.createdTasks, issueId, companyId, taskHandoffs.handoffs, interactions]); const [pendingComposerAssignee, setPendingComposerAssignee] = useState< string | null >(null); @@ -1420,6 +1430,9 @@ export function TaskChatThread(props: TaskChatThreadProps) { for (const item of createdSkillItems) { entries.push({ id: item.id, item, ms: toMs(item.timestamp), order: 2 }); } + for (const item of createdTaskItems) { + entries.push({ id: item.id, item, ms: toMs(item.timestamp), order: 3 }); + } // Older sessions arrive with their surrounding messages when history is // paged in; otherwise they would collect above the loaded conversation. const historyStart = hasOlderComments @@ -1447,6 +1460,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { }, [ createdProjectItems, createdSkillItems, + createdTaskItems, browsers, hasOlderComments, comments, diff --git a/ui/src/components/task-chat/TaskChatTaskCreatedCard.tsx b/ui/src/components/task-chat/TaskChatTaskCreatedCard.tsx new file mode 100644 index 00000000000..0cda27a57d3 --- /dev/null +++ b/ui/src/components/task-chat/TaskChatTaskCreatedCard.tsx @@ -0,0 +1,27 @@ +import { ArrowUpRight, ListTree } from "lucide-react"; +import { Link } from "@/lib/router"; +import { IssueStatusBadge } from "@/components/StatusBadge"; +import type { TaskChatTaskCreatedItem } from "./task-chat-model"; + +export function TaskChatTaskCreatedCard({ item }: { item: TaskChatTaskCreatedItem }) { + const { task } = item; + return ( + + + + {item.isSubtask ? "Subtask created" : "Task created"} + {task.title} + + {task.identifier && {task.identifier}} + + + + + + ); +} diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx index 7c95a95f071..024c5ca1967 100644 --- a/ui/src/components/task-chat/TaskChatThreadView.tsx +++ b/ui/src/components/task-chat/TaskChatThreadView.tsx @@ -1,6 +1,7 @@ import { TaskBrowserActivity } from "../task-side-panel/TaskBrowserActivity"; import { TaskChatProjectCreatedCard } from "./TaskChatProjectCreatedCard"; import { TaskChatSkillCreatedCard } from "./TaskChatSkillCreatedCard"; +import { TaskChatTaskCreatedCard } from "./TaskChatTaskCreatedCard"; import { useMemo, type ReactNode } from "react"; import type { IssueAttachment } from "@paperclipai/shared"; import { cn } from "@/lib/utils"; @@ -111,6 +112,7 @@ function renderItem( case "browser": return ; case "project_created": return ; case "skill_created": return ; + case "task_created": return ; case "message": { // Compute the actions once: the bubble renders them for a runless reply // (footer = actions + timestamp), while an attached turn hands them to diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index d53fbe06b0d..b2c877a26f3 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -13,6 +13,7 @@ * CSS motion tokens in ui/src/index.css. */ import type { + Issue, TaskBrowser, IssueAttachment, IssueCommentMetadata, @@ -556,6 +557,14 @@ export interface TaskChatTurnItem { }; } +export interface TaskChatTaskCreatedItem { + id: string; + kind: "task_created"; + task: Pick; + isSubtask: boolean; + timestamp: string; +} + export interface TaskChatProjectCreatedItem { id: string; kind: "project_created"; @@ -590,6 +599,7 @@ export type TaskChatItem = | { id: string; kind: "decision_receipt"; receipt: DecisionHistoryItem } | { id: string; kind: "task_handoff"; handoff: ConversationTaskHandoff; animateEntry?: boolean } | TaskChatBrowserItem + | TaskChatTaskCreatedItem | TaskChatProjectCreatedItem | TaskChatSkillCreatedItem | TaskChatMessageItem diff --git a/ui/src/components/task-chat/task-created-items.ts b/ui/src/components/task-chat/task-created-items.ts new file mode 100644 index 00000000000..b7ac4b47a76 --- /dev/null +++ b/ui/src/components/task-chat/task-created-items.ts @@ -0,0 +1,23 @@ +import type { Issue } from "@paperclipai/shared"; +import type { TaskChatTaskCreatedItem } from "./task-chat-model"; + +/** Tasks come from the existing access-checked child/source-task reads, never tool output. */ +export function taskCreatedItems( + tasks: readonly Issue[], + issueId: string | null, + companyId: string | null | undefined, + representedTaskIds: ReadonlySet, +): TaskChatTaskCreatedItem[] { + const seen = new Set(representedTaskIds); + return tasks.flatMap((task) => { + if (!task.createdByAgentId || task.id === issueId || seen.has(task.id) || task.companyId !== companyId) return []; + seen.add(task.id); + return [{ + id: `task-created:${task.id}`, + kind: "task_created" as const, + task: { id: task.id, identifier: task.identifier, title: task.title, status: task.status }, + isSubtask: task.parentId === issueId, + timestamp: new Date(task.createdAt).toISOString(), + }]; + }); +} diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index b8206f5e886..9c9d8d40570 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -1567,6 +1567,44 @@ describe("IssueDetail", () => { await waitForAssertion(() => expect(mockIssueChatThreadRender).toHaveBeenCalled()); }); + it("supplies direct children and source-created tasks to the feed without promoting grandchildren", async () => { + const issue = createIssue(); + const child = createIssue({ id: "child", parentId: issue.id, createdByAgentId: "agent-1" }); + const grandchild = createIssue({ id: "grandchild", parentId: child.id, createdByAgentId: "agent-1" }); + const followup = createIssue({ id: "followup", createdByAgentId: "agent-1" }); + mockIssuesApi.get.mockResolvedValue(issue); + mockIssuesApi.listAll.mockImplementation(async (_companyId, filters) => filters?.createdFromIssueId ? [child, followup] : [child, grandchild]); + await act(async () => renderIssueView()); + await waitForAssertion(() => expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]?.createdTasks).toEqual([child, child, followup])); + const updated = { ...child, status: "done" }; + await act(async () => { queryClient.setQueryData(queryKeys.issues.listByDescendantRoot(issue.companyId, issue.id), [updated, grandchild]); }); + await waitForAssertion(() => expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]?.createdTasks[0]).toMatchObject({ id: "child", status: "done" })); + }); + + it("keeps the conversation readable when created tasks fail to load and offers a retry", async () => { + mockIssuesApi.get.mockResolvedValue(createIssue()); + mockIssuesApi.listAll.mockRejectedValue(new Error("Task read unavailable")); + await act(async () => renderIssueView()); + await waitForAssertion(() => expect(container.textContent).toContain("Could not load created tasks.")); + expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]?.initialHistoryError).toBe(false); + mockIssuesApi.listAll.mockResolvedValue([]); + const retry = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "Retry")!; + await act(async () => retry.click()); + await waitForAssertion(() => expect(container.textContent).not.toContain("Could not load created tasks.")); + }); + + it("loads standalone follow-up cards in the task feed when Streamlined UI is off", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi: false, enableClassicTaskInterface: false }); + const issue = createIssue(); + const followup = createIssue({ id: "standalone", parentId: null, createdByAgentId: "agent-1" }); + mockIssuesApi.get.mockResolvedValue(issue); + mockIssuesApi.listAll.mockImplementation(async (_companyId, filters) => filters?.createdFromIssueId ? [followup] : []); + await act(async () => renderIssueView()); + await waitForAssertion(() => expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]?.createdTasks).toEqual([followup])); + expect(container.querySelector('[data-testid="task-chat-thread"]')).not.toBeNull(); + expect(mockIssuesApi.listAll).toHaveBeenCalledWith(issue.companyId, { createdFromIssueId: issue.id, includeRoutineExecutions: true }); + }); + it("uses Coordinator task presentation and panel scope without changing the Classic preference", async () => { coordinatorPreference.enabled = true; mockInstanceSettingsApi.getExperimental.mockResolvedValue({ diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index aa36865e183..42cc6ddf0c9 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1281,6 +1281,7 @@ type IssueDetailChatTabProps = { locallyQueuedCommentRunIds: ReadonlyMap; interactions: IssueThreadInteraction[]; documents: IssueDocumentSummary[]; + createdTasks: Issue[]; workProducts: IssueWorkProduct[]; attachments: IssueAttachment[]; hasOlderComments: boolean; @@ -1438,6 +1439,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ locallyQueuedCommentRunIds, interactions, documents, + createdTasks, workProducts, attachments, hasOlderComments, @@ -2655,7 +2657,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ interruption={interruption} retryPreparing={retryPreparing} key={conversationMode ? draftKey : issueId} - {...(!classicTaskInterfaceEnabled ? { creationActivity: resolvedActivity } : {})} + {...(!classicTaskInterfaceEnabled ? { creationActivity: resolvedActivity, createdTasks } : {})} onOpenSkill={onOpenSkill} browsers={browsers} onOpenBrowser={onOpenBrowser} @@ -3680,7 +3682,9 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS createdFromIssueId: issue!.id, includeRoutineExecutions: true, }), - enabled: streamlinedTaskDetailEnabled && !!resolvedCompanyId && !!issue?.id && !tasksTab, + enabled: !!resolvedCompanyId && !!issue?.id && !issue.id.startsWith("chat:") && ( + (taskChatShellEnabled && !conversation) || (streamlinedTaskDetailEnabled && !tasksTab) + ), }); const { @@ -3959,6 +3963,10 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), ); }, [issue?.id, rawChildIssues]); + const threadCreatedTasks = useMemo(() => [ + ...childIssues.filter((task) => task.parentId === issue?.id), + ...(createdTasksQuery.data ?? EMPTY_ISSUES), + ], [childIssues, createdTasksQuery.data, issue?.id]); const resolvedTasksTab = useMemo(() => { if (tasksTab) return tasksTab; if (!streamlinedTaskDetailEnabled) return undefined; @@ -8466,7 +8474,17 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS onOpenSkill={handleOpenSkill} browsers={browserQuery.data} onOpenBrowser={(id) => { setOpenBrowserId(id); if (isMobile) setMobilePropsOpen(true); else openTaskSidePanel(); }} - threadHeader={<>{taskChatThreadHeader}} + threadHeader={<>{taskChatThreadHeader} + {(childIssuesError || createdTasksQuery.isError) && ( +
+ Could not load created tasks. + +
+ )} + } issueBrief={ // Suppress the seeded-description bubble for the onboarding first // task: its description is agent instructions, not something the @@ -8568,6 +8586,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS locallyQueuedCommentRunIds={locallyQueuedCommentRunIds} interactions={interactions} documents={issue.documentSummaries ?? []} + createdTasks={threadCreatedTasks} workProducts={workProducts ?? issue.workProducts ?? []} attachments={attachments ?? []} hasOlderComments={paginatedConversation && !historyExpanded diff --git a/ui/storybook/stories/task-coordination/task-created-inline.stories.tsx b/ui/storybook/stories/task-coordination/task-created-inline.stories.tsx new file mode 100644 index 00000000000..6f3d1f1ae5a --- /dev/null +++ b/ui/storybook/stories/task-coordination/task-created-inline.stories.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Issue, IssueComment, IssueDocumentSummary } from "@paperclipai/shared"; +import { TaskChatThread } from "@/components/TaskChatThread"; +import { storybookAgents, storybookIssues } from "../../fixtures/paperclipData"; +import { queryKeys } from "@/lib/queryKeys"; + +const parent = storybookIssues[0]!; +const at = (minute: number) => new Date(`2026-10-30T09:${String(minute).padStart(2, "0")}:00Z`); +const task: Issue = { + ...parent, id: "inline-subtask", parentId: parent.id, identifier: "PAP-42", + title: "Fix incomplete search results when the task list has multiple pages", + status: "in_progress", createdByAgentId: "agent-1", createdAt: at(2), +}; +const comments: IssueComment[] = [ + { id: "request", authorType: "user", authorUserId: "viewer", body: "Review the new findings and create a task for each repair that needs work.", createdAt: at(0) }, + { id: "progress", authorType: "agent", authorAgentId: "agent-1", body: "I found one actionable issue. I’ll create a subtask for the repair and save the batch report here.", createdAt: at(1) }, + { id: "response", authorType: "agent", authorAgentId: "agent-1", body: "Created one repair task. The batch report has the source evidence and selection details.", createdAt: at(4) }, +].map((comment) => ({ + ...comment, authorType: comment.authorType as "user" | "agent", + authorAgentId: comment.authorAgentId ?? null, authorUserId: comment.authorUserId ?? null, + presentation: null, metadata: null, companyId: parent.companyId, issueId: parent.id, updatedAt: comment.createdAt, +})); +const report = { + id: "report", issueId: parent.id, companyId: parent.companyId, key: "batch-report", title: "Batch report", + format: "markdown", latestRevisionNumber: 1, latestRevisionId: "report-1", createdAt: at(3), updatedAt: at(3), +} as IssueDocumentSummary; + +function TaskFeed({ multiple = false }: { multiple?: boolean }) { + const [client] = useState(() => { + const cache = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + cache.setQueryData([...queryKeys.issues.documents(parent.id), "plan"], null); + return cache; + }); + return +
+ {}} showComposer={false} + threadHeader={

Task feed

Daily findings and repair tasks

} + /> +
+
; +} + +const meta = { + id: "task-created-inline", + title: "Tasks and conversations/Delegation, dependencies, and signoff/Created task cards", + component: TaskFeed, + parameters: { + layout: "fullscreen", initialEntries: ["/PAP/issues/parent"], + paperclip: { + capabilities: ["task-coordination/delegation", "tasks/discussion"], + implementation: "production", boundary: "fixtures", + notes: "Production TaskChatThread assembles created task cards beside a saved document. Task reads and status are fixtures; no agent runs or task writes occur.", + }, + }, + globals: { theme: "light" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const InlineSubtask: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const card = await canvas.findByTestId("task-chat-created-task"); + await expect(card).toHaveTextContent("Subtask created"); + await expect(card).toHaveTextContent("In progress"); + await expect(card).toHaveAttribute("href", "/PAP/issues/PAP-42"); + await expect(canvas.getAllByTestId("task-chat-created-task")).toHaveLength(1); + }, +}; +export const MultipleSubtasks: Story = { args: { multiple: true } }; +export const Dark: Story = { globals: { theme: "dark" } }; +export const Mobile: Story = { args: { multiple: true }, globals: { viewport: { value: "mobile", isRotated: false } } };