Files
PaperClipAI/ui/src/components/TaskChatThread.task-creation-order.test.tsx
T
DottaandPaperclip 909e29dd07 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 <noreply@paperclip.ing>
2026-10-30 21:42:24 -05:00

225 lines
13 KiB
TypeScript

// @vitest-environment jsdom
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, Issue, IssueDocument, IssueThreadInteraction } from "@paperclipai/shared";
import { TaskChatThread } from "./TaskChatThread";
import { CoordinatorPresentation } from "./coordinator/CoordinatorPresentation";
import { ThemeProvider } from "@/context/ThemeContext";
import { pendingSuggestedTasksInteraction } from "@/fixtures/issueThreadInteractionFixtures";
import type { TranscriptEntry } from "@/adapters";
import type { LiveRunForIssue } from "@/api/heartbeats";
import type { IssueChatLinkedRun } from "@/lib/issue-chat-messages";
const state = vi.hoisted(() => ({
coordinator: true,
transcripts: new Map<string, TranscriptEntry[]>(),
handoffs: [] as ConversationTaskHandoff[],
plan: null as IssueDocument | null,
}));
vi.mock("@/components/transcript/useLiveRunTranscripts", () => ({
useLiveRunTranscripts: () => ({ transcriptByRun: new Map(), isInitialHydrating: false }),
}));
vi.mock("@/components/transcript/useNativeRunTranscripts", () => ({
useNativeRunTranscripts: () => ({ transcriptByRun: new Map(state.transcripts), errorsByRun: new Map(),
terminalHydratedRunIds: new Set(state.transcripts.keys()) }),
}));
vi.mock("@/hooks/useIssuePlanDocument", () => ({ useIssuePlanDocument: () => ({ data: state.plan }) }));
vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: false }) }));
vi.mock("@/hooks/useStreamlinedUiEnabled", () => ({ useStreamlinedUiEnabled: () => ({ enabled: true, loaded: true }) }));
vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({
useCoordinatorWorkspaceEnabled: () => ({ enabled: state.coordinator, loaded: true, userId: "viewer" }),
}));
vi.mock("@/components/coordinator/CoordinatorChatTaskHandoffs", async importOriginal => ({
...await importOriginal<typeof import("@/components/coordinator/CoordinatorChatTaskHandoffs")>(),
useCoordinatorChatTaskHandoffs: () => ({ handoffs: state.coordinator ? state.handoffs : [], isSuccess: true, enabled: state.coordinator }),
}));
vi.mock("@/components/coordinator/CoordinatorChatDecisionReceipts", async importOriginal => ({
...await importOriginal<typeof import("@/components/coordinator/CoordinatorChatDecisionReceipts")>(),
useCoordinatorChatDecisionReceipts: () => ({ receipts: [], enabled: false }),
}));
vi.mock("@/components/coordinator/CoordinatorChatPendingQuestions", () => ({
useCoordinatorChatPendingQuestions: () => ({ questions: [], enabled: false }),
CoordinatorChatQuestionStatus: () => null,
}));
vi.mock("@/api/issues", async importOriginal => ({
...await importOriginal<typeof import("@/api/issues")>(),
issuesApi: { summaries: vi.fn(async () => []) },
}));
vi.mock("@/lib/router", () => ({
useLocation: () => ({ pathname: "/issues/chat", hash: "", search: "", key: "test", state: null }),
Link: ({ to, children, ...props }: { to: string; children: React.ReactNode }) => <a href={to} {...props}>{children}</a>,
}));
vi.mock("@/components/MarkdownEditor", () => ({
MarkdownEditor: forwardRef(function MockMarkdownEditor({ value }: { value: string }, ref: ForwardedRef<unknown>) {
useImperativeHandle(ref, () => ({ insertMarkdown: () => {}, focus: () => {} }));
return <div>{value}</div>;
}),
}));
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let host: HTMLDivElement;
let root: Root;
let client: QueryClient;
const at = (second: number) => `2026-10-26T12:00:${String(second).padStart(2, "0")}Z`;
const sourceRun: IssueChatLinkedRun = {
runId: "source-run", status: "succeeded", runtimeMode: "native", adapterType: "paperclip_runner",
agentId: "agent", agentName: "Assistant", createdAt: at(0), startedAt: at(0), finishedAt: at(10),
};
const comment = (id: string, body: string, second: number, runId?: string) => ({
id, body, companyId: "company", issueId: "chat", authorType: runId ? "agent" as const : "user" as const,
authorAgentId: runId ? "agent" : null, authorUserId: runId ? null : "viewer", runId,
createdByRunId: runId ?? null, presentation: null, metadata: null,
createdAt: new Date(at(second)), updatedAt: new Date(at(second)),
});
const handoff = (id = "task", sourceRunId: string | null = "source-run", second = 3): ConversationTaskHandoff => ({
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> = {}): 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)),
result: status === "accepted" ? { version: 1, createdTasks: [{ clientKey: "post", issueId: "suggested-task", identifier: "PAPER-101", title: "Suggested task" }] } : null,
});
beforeEach(() => {
state.coordinator = true;
state.handoffs = [];
state.plan = null;
state.transcripts.clear();
state.transcripts.set("source-run", [
{ kind: "assistant", channel: "progress", text: "Preparing the task.", ts: at(2) },
{ kind: "thinking", text: "Check the requested scope.", ts: at(4) },
{ kind: "assistant", channel: "final", text: "Your task is ready.", ts: at(9) },
]);
localStorage.clear();
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() });
});
afterEach(async () => { await act(() => root.unmount()); host.remove(); client.clear(); vi.restoreAllMocks(); });
async function paint(overrides: Partial<React.ComponentProps<typeof TaskChatThread>> = {}) {
await act(() => root.render(<QueryClientProvider client={client}><ThemeProvider>
<CoordinatorPresentation value={state.coordinator}>
<TaskChatThread issueId="chat" conversationMode currentUserId="viewer" comments={[
comment("final", "Your task is ready.", 9, "source-run"), comment("next", "A later question.", 20),
]} linkedRuns={[sourceRun]} onAdd={async () => {}} {...overrides} />
</CoordinatorPresentation>
</ThemeProvider></QueryClientProvider>));
}
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<HTMLAnchorElement>('[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<HTMLAnchorElement>('[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,
createdAt: new Date(at(2)), updatedAt: new Date(at(2)), updatedByAgentId: "agent" } as IssueDocument;
const result = { ...state.plan, id: "concepts", key: "concepts", title: "Concept document", body: "The concepts.",
createdAt: new Date(at(5)), updatedAt: new Date(at(5)) };
await paint({ documents: [state.plan, result], conversationMode: false });
const plan = host.querySelector('[data-testid="task-chat-plan-preview"]')!;
const output = host.querySelector('a[href="#document-concepts"]')!;
expect(plan).toBeTruthy();
expect(output).toBeTruthy();
before(plan, output);
expect(host.querySelectorAll('[data-testid="task-chat-plan-preview"]')).toHaveLength(1);
});
it("puts direct task cards after their complete source reply, before a later conversation", async () => {
state.handoffs = [handoff(), handoff("unknown", null, 5)];
await paint();
before(anchor("final"), anchor("chat_handoff:task"));
before(anchor("chat_handoff:task"), anchor("next"));
before(anchor("chat_handoff:unknown"), anchor("final"));
expect(host.querySelectorAll('[data-thread-anchor="chat_handoff:task"]')).toHaveLength(1);
});
it("puts cards after transcript-only responses and every segment of a steered turn", async () => {
state.handoffs = [handoff()];
await paint({ comments: [
{ ...comment("steer", "Keep the task small.", 5), steeredIntoRunId: "source-run", conversationAnchorAt: at(5) },
comment("next", "A later question.", 20),
] });
before(anchor("source-run:turn:1"), anchor("chat_handoff:task"));
before(anchor("chat_handoff:task"), anchor("next"));
expect(anchor("source-run:turn:1").textContent).toContain("Your task is ready.");
});
it("keeps a suggestion in the same end-of-turn slot when acceptance creates its task", async () => {
await paint({ interactions: [suggestion("pending")] });
const card = anchor("interaction:suggestion");
before(anchor("final"), card);
before(card, anchor("next"));
state.handoffs = [{ ...handoff("suggested-task"), suggestionInteractionId: "suggestion" }];
await paint({ interactions: [suggestion("accepted")] });
expect(anchor("interaction:suggestion")).toBe(card);
expect(card.querySelector(".coordinator-created-task")).not.toBeNull();
expect(host.querySelector('[data-thread-anchor="chat_handoff:suggested-task"]')).toBeNull();
before(anchor("final"), card);
before(card, anchor("next"));
});
it("follows the live tail and stays after the final reply when the turn settles", async () => {
state.handoffs = [handoff()];
const live: LiveRunForIssue = { id: "source-run", agentId: "agent", agentName: "Assistant", adapterType: "paperclip_runner",
runtimeMode: "native", status: "running", invocationSource: "on_demand", triggerDetail: null,
createdAt: at(0), startedAt: at(0), finishedAt: null };
await paint({ comments: [], linkedRuns: [{ ...sourceRun, status: "running", finishedAt: null }], liveRuns: [live] });
before(host.querySelector('[data-testid="task-chat-live-transcript"]')!, anchor("chat_handoff:task"));
await paint();
before(anchor("final"), anchor("chat_handoff:task"));
before(anchor("chat_handoff:task"), anchor("next"));
expect(host.querySelectorAll('[data-thread-anchor="chat_handoff:task"]')).toHaveLength(1);
});
it("keeps classic suggestion chronology when Coordinator is disabled", async () => {
state.coordinator = false;
await paint({ interactions: [suggestion("accepted")] });
before(anchor("interaction:suggestion"), anchor("final"));
});
});