diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index f3ede69d374..a1a0d59dad0 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -307,6 +307,7 @@ export interface SuggestTasksResultCreatedTask { export interface SuggestTasksResult { version: 1; createdTasks?: SuggestTasksResultCreatedTask[]; + skippedClientKeys?: string[]; rejectionReason?: string | null; } diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 68b08824312..7760b6c60fa 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -247,6 +247,7 @@ export const suggestTasksResultCreatedTaskSchema = z.object({ export const suggestTasksResultSchema = z.object({ version: z.literal(1), createdTasks: z.array(suggestTasksResultCreatedTaskSchema).max(50).optional(), + skippedClientKeys: z.array(z.string().trim().min(1).max(120)).max(50).optional(), rejectionReason: z.string().trim().max(4000).nullable().optional(), }); @@ -332,7 +333,22 @@ export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [ export type CreateIssueThreadInteraction = z.infer; -export const acceptIssueThreadInteractionSchema = z.object({}); +export const acceptIssueThreadInteractionSchema = z.object({ + selectedClientKeys: z.array(z.string().trim().min(1).max(120)).min(1).max(50).optional(), +}).superRefine((value, ctx) => { + const seenClientKeys = new Set(); + for (const [index, clientKey] of (value.selectedClientKeys ?? []).entries()) { + if (seenClientKeys.has(clientKey)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "selectedClientKeys must be unique", + path: ["selectedClientKeys", index], + }); + continue; + } + seenClientKeys.add(clientKey); + } +}); export type AcceptIssueThreadInteraction = z.infer; export const rejectIssueThreadInteractionSchema = z.object({ diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index 04ce1c2cfe8..4fe18485054 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -161,6 +161,7 @@ describe("issue thread interaction routes", () => { result: { version: 1, createdTasks: [{ clientKey: "task-1", issueId: "child-1" }], + skippedClientKeys: ["task-2"], }, createdAt: "2026-04-20T12:00:00.000Z", updatedAt: "2026-04-20T12:05:00.000Z", @@ -266,10 +267,15 @@ describe("issue thread interaction routes", () => { const res = await request(app) .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-1/accept") - .send({}); + .send({ selectedClientKeys: ["task-1"] }); expect(res.status).toBe(200); - expect(mockInteractionService.acceptSuggestedTasks).toHaveBeenCalled(); + expect(mockInteractionService.acceptSuggestedTasks).toHaveBeenCalledWith( + expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }), + "interaction-1", + { selectedClientKeys: ["task-1"] }, + expect.objectContaining({ userId: "local-board" }), + ); expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(2); expect(mockHeartbeatService.wakeup).toHaveBeenNthCalledWith( 1, diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index 85b7c4a87b2..56c898dcea7 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -124,7 +124,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { companyId, goalId, projectId: null, - }, created.id, { + }, created.id, {}, { userId: "local-board", }); @@ -162,6 +162,155 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { expect(listed[0]?.status).toBe("accepted"); }); + it("accepts a selected subset of suggested tasks and records the skipped drafts", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); + + await db.insert(goals).values({ + id: goalId, + companyId, + title: "Selectively persist thread interactions", + level: "task", + status: "active", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + requestDepth: 2, + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "suggest_tasks", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + tasks: [ + { + clientKey: "root", + title: "Create the root follow-up", + }, + { + clientKey: "child", + parentClientKey: "root", + title: "Create the nested follow-up", + }, + { + clientKey: "sibling", + title: "Create the sibling follow-up", + }, + ], + }, + }, { + userId: "local-board", + }); + + const accepted = await interactionsSvc.acceptSuggestedTasks({ + id: issueId, + companyId, + goalId, + projectId: null, + }, created.id, { + selectedClientKeys: ["root"], + }, { + userId: "local-board", + }); + + expect(accepted.interaction.result).toMatchObject({ + version: 1, + createdTasks: [ + expect.objectContaining({ clientKey: "root", parentIssueId: issueId }), + ], + skippedClientKeys: ["child", "sibling"], + }); + + const children = await issuesSvc.list(companyId, { parentId: issueId }); + expect(children).toHaveLength(1); + expect(children[0]?.title).toBe("Create the root follow-up"); + }); + + it("rejects partial acceptance when a selected task omits its selected-tree parent", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); + + await db.insert(goals).values({ + id: goalId, + companyId, + title: "Validate selective acceptance", + level: "task", + status: "active", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "suggest_tasks", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + tasks: [ + { + clientKey: "root", + title: "Create the root follow-up", + }, + { + clientKey: "child", + parentClientKey: "root", + title: "Create the nested follow-up", + }, + ], + }, + }, { + userId: "local-board", + }); + + await expect( + interactionsSvc.acceptSuggestedTasks({ + id: issueId, + companyId, + goalId, + projectId: null, + }, created.id, { + selectedClientKeys: ["child"], + }, { + userId: "local-board", + }), + ).rejects.toThrow("requires its parent"); + }); + it("persists validated answers for ask_user_questions interactions", async () => { const companyId = randomUUID(); const goalId = randomUUID(); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 1bbc2af7d4e..377d13a589f 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -2569,7 +2569,7 @@ export function issueRoutes( assertBoard(req); const actor = getActorInfo(req); - const { interaction, createdIssues } = await issueThreadInteractionService(db).acceptSuggestedTasks(issue, interactionId, { + const { interaction, createdIssues } = await issueThreadInteractionService(db).acceptSuggestedTasks(issue, interactionId, req.body, { agentId: actor.agentId, userId: actor.actorType === "user" ? actor.actorId : null, }); @@ -2591,6 +2591,10 @@ export function issueRoutes( interaction.kind === "suggest_tasks" ? (interaction.result?.createdTasks?.length ?? 0) : 0, + skippedTaskCount: + interaction.kind === "suggest_tasks" + ? (interaction.result?.skippedClientKeys?.length ?? 0) + : 0, }, }); diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index ce326b828b2..577c4653aca 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -7,6 +7,7 @@ import { issues, } from "@paperclipai/db"; import type { + AcceptIssueThreadInteraction, AskUserQuestionsAnswer, AskUserQuestionsInteraction, CreateIssueThreadInteraction, @@ -17,6 +18,7 @@ import type { SuggestTasksResultCreatedTask, } from "@paperclipai/shared"; import { + acceptIssueThreadInteractionSchema, askUserQuestionsPayloadSchema, askUserQuestionsResultSchema, createIssueThreadInteractionSchema, @@ -137,6 +139,46 @@ function buildTaskCreationOrder(tasks: ReadonlyArray [task.clientKey, task] as const), + ); + const selectedClientKeys = args.selectedClientKeys ?? args.interaction.payload.tasks.map((task) => task.clientKey); + const selectedClientKeySet = new Set(); + + for (const clientKey of selectedClientKeys) { + const task = taskByClientKey.get(clientKey); + if (!task) { + throw unprocessable(`Unknown suggested task clientKey: ${clientKey}`); + } + selectedClientKeySet.add(clientKey); + } + + if (selectedClientKeySet.size === 0) { + throw unprocessable("Select at least one suggested task to accept"); + } + + for (const clientKey of selectedClientKeySet) { + let parentClientKey = taskByClientKey.get(clientKey)?.parentClientKey ?? null; + while (parentClientKey) { + if (!selectedClientKeySet.has(parentClientKey)) { + throw unprocessable(`Suggested task ${clientKey} requires its parent ${parentClientKey} to also be selected`); + } + parentClientKey = taskByClientKey.get(parentClientKey)?.parentClientKey ?? null; + } + } + + return { + selectedTasks: args.interaction.payload.tasks.filter((task) => selectedClientKeySet.has(task.clientKey)), + skippedClientKeys: args.interaction.payload.tasks + .filter((task) => !selectedClientKeySet.has(task.clientKey)) + .map((task) => task.clientKey), + }; +} + function normalizeQuestionAnswers(args: { questions: AskUserQuestionsInteraction["payload"]["questions"]; answers: RespondIssueThreadInteraction["answers"]; @@ -316,8 +358,10 @@ export function issueThreadInteractionService(db: Db) { acceptSuggestedTasks: async ( issue: { id: string; companyId: string; projectId: string | null; goalId: string | null }, interactionId: string, + input: AcceptIssueThreadInteraction, actor: InteractionActor, ) => { + const data = acceptIssueThreadInteractionSchema.parse(input); const current = await db .select() .from(issueThreadInteractions) @@ -336,11 +380,15 @@ export function issueThreadInteractionService(db: Db) { } const interaction = hydrateInteraction(current) as SuggestTasksInteraction; - const orderedTasks = buildTaskCreationOrder(interaction.payload.tasks); + const { selectedTasks, skippedClientKeys } = resolveSelectedSuggestedTasks({ + interaction, + selectedClientKeys: data.selectedClientKeys, + }); + const orderedTasks = buildTaskCreationOrder(selectedTasks); const explicitParentIds = [...new Set([ issue.id, ...(interaction.payload.defaultParentId ? [interaction.payload.defaultParentId] : []), - ...interaction.payload.tasks + ...selectedTasks .map((task) => task.parentId ?? null) .filter((value): value is string => Boolean(value)), ])]; @@ -413,6 +461,7 @@ export function issueThreadInteractionService(db: Db) { result: { version: 1, createdTasks: [...createdByClientKey.values()], + ...(skippedClientKeys.length > 0 ? { skippedClientKeys } : {}), }, resolvedByAgentId: actor.agentId ?? null, resolvedByUserId: actor.userId ?? null, diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 782c49216ae..5667af7b5fa 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -106,8 +106,12 @@ export const issuesApi = { api.get(`/issues/${id}/interactions`), createInteraction: (id: string, data: Record) => api.post(`/issues/${id}/interactions`, data), - acceptInteraction: (id: string, interactionId: string) => - api.post(`/issues/${id}/interactions/${interactionId}/accept`, {}), + acceptInteraction: ( + id: string, + interactionId: string, + data?: { selectedClientKeys?: string[] }, + ) => + api.post(`/issues/${id}/interactions/${interactionId}/accept`, data ?? {}), rejectInteraction: (id: string, interactionId: string, reason?: string) => api.post(`/issues/${id}/interactions/${interactionId}/reject`, reason ? { reason } : {}), respondToInteraction: ( diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 601110da80d..c6b163ac68e 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -412,6 +412,71 @@ describe("IssueChatThread", () => { id: "interaction-suggest-1", kind: "suggest_tasks", }), + ["task-1"], + ); + + act(() => { + root.unmount(); + }); + }); + + it("submits only the selected draft subtree when tasks are manually pruned", async () => { + const root = createRoot(container); + const onAcceptInteraction = vi.fn(async () => undefined); + + await act(async () => { + root.render( + + {}} + onAcceptInteraction={onAcceptInteraction} + showComposer={false} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const childCheckbox = container.querySelector('[aria-label="Include Child task"]'); + expect(childCheckbox).toBeTruthy(); + + await act(async () => { + childCheckbox?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + const acceptButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Accept selected drafts"), + ); + expect(acceptButton).toBeTruthy(); + await act(async () => { + acceptButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onAcceptInteraction).toHaveBeenCalledWith( + expect.objectContaining({ + id: "interaction-suggest-1", + kind: "suggest_tasks", + }), + ["root"], ); act(() => { diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 32512224550..12ba7d5c6b1 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -122,7 +122,10 @@ interface IssueChatMessageContext { onCancelQueued?: (commentId: string) => void; interruptingQueuedRunId?: string | null; onImageClick?: (src: string) => void; - onAcceptInteraction?: (interaction: SuggestTasksInteraction) => Promise | void; + onAcceptInteraction?: ( + interaction: SuggestTasksInteraction, + selectedClientKeys?: string[], + ) => Promise | void; onRejectInteraction?: ( interaction: SuggestTasksInteraction, reason?: string, @@ -274,7 +277,10 @@ interface IssueChatThreadProps { interruptingQueuedRunId?: string | null; stoppingRunId?: string | null; onImageClick?: (src: string) => void; - onAcceptInteraction?: (interaction: SuggestTasksInteraction) => Promise | void; + onAcceptInteraction?: ( + interaction: SuggestTasksInteraction, + selectedClientKeys?: string[], + ) => Promise | void; onRejectInteraction?: ( interaction: SuggestTasksInteraction, reason?: string, diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx index 0227fa939d9..7880a967daf 100644 --- a/ui/src/components/IssueThreadInteractionCard.tsx +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -5,6 +5,7 @@ import { Link } from "@/lib/router"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { buildSuggestedTaskTree, + collectSuggestedTaskClientKeys, countSuggestedTaskNodes, getQuestionAnswerLabels, type AskUserQuestionsAnswer, @@ -18,6 +19,7 @@ import { import { cn, formatDateTime, formatShortDate } from "../lib/utils"; import { MarkdownBody } from "./MarkdownBody"; import { Button } from "./ui/button"; +import { Checkbox } from "./ui/checkbox"; import { Textarea } from "./ui/textarea"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; @@ -26,7 +28,10 @@ interface IssueThreadInteractionCardProps { agentMap?: Map; currentUserId?: string | null; userLabelMap?: ReadonlyMap | null; - onAcceptInteraction?: (interaction: SuggestTasksInteraction) => Promise | void; + onAcceptInteraction?: ( + interaction: SuggestTasksInteraction, + selectedClientKeys?: string[], + ) => Promise | void; onRejectInteraction?: ( interaction: SuggestTasksInteraction, reason?: string, @@ -153,6 +158,10 @@ function TaskTreeNode({ currentUserId, userLabelMap, depth = 0, + selectedClientKeys, + skippedClientKeys, + showSelection, + onToggleSelection, }: { node: SuggestedTaskTreeNode; createdByClientKey: ReadonlyMap; @@ -160,12 +169,18 @@ function TaskTreeNode({ currentUserId?: string | null; userLabelMap?: ReadonlyMap | null; depth?: number; + selectedClientKeys?: ReadonlySet; + skippedClientKeys?: ReadonlySet; + showSelection?: boolean; + onToggleSelection?: (node: SuggestedTaskTreeNode, checked: boolean) => void; }) { const visibleChildren = node.children.filter((child) => !child.task.hiddenInPreview); const hiddenChildCount = node.children .filter((child) => child.task.hiddenInPreview) .reduce((sum, child) => sum + countSuggestedTaskNodes(child), 0); const createdTask = createdByClientKey.get(node.task.clientKey); + const isSelected = selectedClientKeys?.has(node.task.clientKey) ?? false; + const isSkipped = skippedClientKeys?.has(node.task.clientKey) ?? false; const assigneeLabel = resolveActorLabel({ agentId: node.task.assigneeAgentId, userId: node.task.assigneeUserId, @@ -187,7 +202,15 @@ function TaskTreeNode({ >
-
+
+ {showSelection ? ( + onToggleSelection?.(node, checked === true)} + aria-label={`Include ${node.task.title}`} + className="mt-1" + /> + ) : null} @@ -212,6 +235,10 @@ function TaskTreeNode({ {createdTask.identifier ?? createdTask.issueId.slice(0, 8)} + ) : isSkipped ? ( + + Skipped + ) : null}
@@ -256,6 +283,10 @@ function TaskTreeNode({ currentUserId={currentUserId} userLabelMap={userLabelMap} depth={depth + 1} + selectedClientKeys={selectedClientKeys} + skippedClientKeys={skippedClientKeys} + showSelection={showSelection} + onToggleSelection={onToggleSelection} /> ))}
@@ -276,7 +307,10 @@ function SuggestTasksCard({ agentMap?: Map; currentUserId?: string | null; userLabelMap?: ReadonlyMap | null; - onAcceptInteraction?: (interaction: SuggestTasksInteraction) => Promise | void; + onAcceptInteraction?: ( + interaction: SuggestTasksInteraction, + selectedClientKeys?: string[], + ) => Promise | void; onRejectInteraction?: ( interaction: SuggestTasksInteraction, reason?: string, @@ -307,13 +341,36 @@ function SuggestTasksCard({ () => createdTaskMap(interaction.result?.createdTasks), [interaction.result?.createdTasks], ); + const skippedClientKeys = useMemo( + () => new Set(interaction.result?.skippedClientKeys ?? []), + [interaction.result?.skippedClientKeys], + ); const totalTasks = interaction.payload.tasks.length; + const [selectedClientKeys, setSelectedClientKeys] = useState>( + () => new Set(interaction.payload.tasks.map((task) => task.clientKey)), + ); + const taskSelectionSeed = useMemo( + () => interaction.payload.tasks.map((task) => task.clientKey).join("\n"), + [interaction.payload.tasks], + ); + + useEffect(() => { + setSelectedClientKeys(new Set(interaction.payload.tasks.map((task) => task.clientKey))); + }, [interaction.id, interaction.status, taskSelectionSeed]); + + const taskByClientKey = useMemo( + () => new Map(interaction.payload.tasks.map((task) => [task.clientKey, task] as const)), + [interaction.payload.tasks], + ); + const selectedCount = selectedClientKeys.size; + const createdCount = interaction.result?.createdTasks?.length ?? 0; + const skippedCount = interaction.result?.skippedClientKeys?.length ?? 0; async function handleAccept() { if (!onAcceptInteraction) return; setWorking("accept"); try { - await onAcceptInteraction(interaction); + await onAcceptInteraction(interaction, [...selectedClientKeys]); } finally { setWorking(null); } @@ -330,6 +387,30 @@ function SuggestTasksCard({ } } + function handleToggleSelection(node: SuggestedTaskTreeNode, checked: boolean) { + const subtreeClientKeys = collectSuggestedTaskClientKeys(node); + setSelectedClientKeys((current) => { + const next = new Set(current); + if (!checked) { + for (const clientKey of subtreeClientKeys) { + next.delete(clientKey); + } + return next; + } + + for (const clientKey of subtreeClientKeys) { + next.add(clientKey); + } + + let parentClientKey = taskByClientKey.get(node.task.clientKey)?.parentClientKey ?? null; + while (parentClientKey) { + next.add(parentClientKey); + parentClientKey = taskByClientKey.get(parentClientKey)?.parentClientKey ?? null; + } + return next; + }); + } + return (
@@ -352,10 +433,27 @@ function SuggestTasksCard({ agentMap={agentMap} currentUserId={currentUserId} userLabelMap={userLabelMap} + selectedClientKeys={selectedClientKeys} + skippedClientKeys={skippedClientKeys} + showSelection={interaction.status === "pending"} + onToggleSelection={handleToggleSelection} /> ))}
+ {interaction.status === "accepted" ? ( +
+
+ Resolution summary +
+

+ {skippedCount > 0 + ? `Created ${createdCount} draft ${createdCount === 1 ? "issue" : "issues"} and skipped ${skippedCount} during review.` + : `Created all ${createdCount} draft ${createdCount === 1 ? "issue" : "issues"}.`} +

+
+ ) : null} + {interaction.status === "rejected" && interaction.result?.rejectionReason ? (
@@ -367,10 +465,23 @@ function SuggestTasksCard({ {interaction.status === "pending" ? (
+
+ + {selectedCount === totalTasks + ? `All ${totalTasks} draft ${totalTasks === 1 ? "issue" : "issues"} selected` + : `${selectedCount} of ${totalTasks} draft ${totalTasks === 1 ? "issue" : "issues"} selected`} + + {selectedCount < totalTasks ? ( + + {totalTasks - selectedCount} will be skipped if you accept this interaction. + + ) : null} +
+
+ {selectedCount < totalTasks ? ( + + ) : null}
{rejecting ? ( diff --git a/ui/src/lib/issue-thread-interactions.test.ts b/ui/src/lib/issue-thread-interactions.test.ts index 2fb6e1b9689..ec744b28370 100644 --- a/ui/src/lib/issue-thread-interactions.test.ts +++ b/ui/src/lib/issue-thread-interactions.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildIssueThreadInteractionSummary, buildSuggestedTaskTree, + collectSuggestedTaskClientKeys, countSuggestedTaskNodes, getQuestionAnswerLabels, } from "./issue-thread-interactions"; @@ -29,6 +30,7 @@ describe("buildSuggestedTaskTree", () => { expect(roots[0]?.task.clientKey).toBe("root"); expect(roots[0]?.children[0]?.task.clientKey).toBe("child"); expect(countSuggestedTaskNodes(roots[0]!)).toBe(3); + expect(collectSuggestedTaskClientKeys(roots[0]!)).toEqual(["root", "child", "grandchild"]); }); }); @@ -52,6 +54,29 @@ describe("issue thread interaction helpers", () => { }, })).toBe("Suggested 2 tasks"); + expect(buildIssueThreadInteractionSummary({ + id: "interaction-accepted", + companyId: "company-1", + issueId: "issue-1", + kind: "suggest_tasks", + status: "accepted", + continuationPolicy: "wake_assignee", + createdAt: "2026-04-06T12:00:00.000Z", + updatedAt: "2026-04-06T12:00:00.000Z", + payload: { + version: 1, + tasks: [ + { clientKey: "task-1", title: "One" }, + { clientKey: "task-2", title: "Two" }, + ], + }, + result: { + version: 1, + createdTasks: [{ clientKey: "task-1", issueId: "child-1" }], + skippedClientKeys: ["task-2"], + }, + })).toBe("Accepted 1 of 2 tasks"); + expect(buildIssueThreadInteractionSummary({ id: "interaction-2", companyId: "company-1", @@ -73,6 +98,32 @@ describe("issue thread interaction helpers", () => { ], }, })).toBe("Asked 1 question"); + + expect(buildIssueThreadInteractionSummary({ + id: "interaction-answered", + companyId: "company-1", + issueId: "issue-1", + kind: "ask_user_questions", + status: "answered", + continuationPolicy: "wake_assignee", + createdAt: "2026-04-06T12:00:00.000Z", + updatedAt: "2026-04-06T12:00:00.000Z", + payload: { + version: 1, + questions: [ + { + id: "question-1", + prompt: "Pick one", + selectionMode: "single", + options: [{ id: "option-1", label: "Option 1" }], + }, + ], + }, + result: { + version: 1, + answers: [{ questionId: "question-1", optionIds: ["option-1"] }], + }, + })).toBe("Answered 1 question"); }); it("maps stored option ids back to labels for answered summaries", () => { diff --git a/ui/src/lib/issue-thread-interactions.ts b/ui/src/lib/issue-thread-interactions.ts index 1b7f35a0436..0bfdf821228 100644 --- a/ui/src/lib/issue-thread-interactions.ts +++ b/ui/src/lib/issue-thread-interactions.ts @@ -47,10 +47,24 @@ export function buildIssueThreadInteractionSummary( ) { if (interaction.kind === "suggest_tasks") { const count = interaction.payload.tasks.length; + if (interaction.status === "accepted") { + const createdCount = interaction.result?.createdTasks?.length ?? 0; + const skippedCount = interaction.result?.skippedClientKeys?.length ?? 0; + if (skippedCount > 0) { + return `Accepted ${createdCount} of ${count} tasks`; + } + return createdCount === 1 ? "Accepted 1 task" : `Accepted ${createdCount} tasks`; + } + if (interaction.status === "rejected") { + return count === 1 ? "Rejected 1 task" : `Rejected ${count} tasks`; + } return count === 1 ? "Suggested 1 task" : `Suggested ${count} tasks`; } const count = interaction.payload.questions.length; + if (interaction.status === "answered") { + return count === 1 ? "Answered 1 question" : `Answered ${count} questions`; + } return count === 1 ? "Asked 1 question" : `Asked ${count} questions`; } @@ -81,6 +95,13 @@ export function countSuggestedTaskNodes(node: SuggestedTaskTreeNode): number { return 1 + node.children.reduce((sum, child) => sum + countSuggestedTaskNodes(child), 0); } +export function collectSuggestedTaskClientKeys(node: SuggestedTaskTreeNode): string[] { + return [ + node.task.clientKey, + ...node.children.flatMap((child) => collectSuggestedTaskClientKeys(child)), + ]; +} + export function getQuestionAnswerLabels(args: { question: AskUserQuestionsQuestion; answers: readonly AskUserQuestionsAnswer[]; diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index b1982b810ad..e70b6c21b97 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -542,7 +542,10 @@ type IssueDetailChatTabProps = { onCancelQueued: (commentId: string) => void; interruptingQueuedRunId: string | null; onImageClick: (src: string) => void; - onAcceptInteraction: (interaction: SuggestTasksInteraction) => Promise; + onAcceptInteraction: ( + interaction: SuggestTasksInteraction, + selectedClientKeys?: string[], + ) => Promise; onRejectInteraction: (interaction: SuggestTasksInteraction, reason?: string) => Promise; onSubmitInteractionAnswers: ( interaction: IssueThreadInteraction, @@ -1540,8 +1543,13 @@ export function IssueDetail() { }, }); const acceptInteraction = useMutation({ - mutationFn: (interaction: SuggestTasksInteraction) => - issuesApi.acceptInteraction(issueId!, interaction.id), + mutationFn: ({ + interaction, + selectedClientKeys, + }: { + interaction: SuggestTasksInteraction; + selectedClientKeys?: string[]; + }) => issuesApi.acceptInteraction(issueId!, interaction.id, { selectedClientKeys }), onSuccess: (interaction) => { upsertInteractionInCache(interaction); if (resolvedCompanyId && issue?.id) { @@ -1549,8 +1557,12 @@ export function IssueDetail() { } invalidateIssueDetail(); invalidateIssueCollections(); + const createdCount = interaction.result?.createdTasks?.length ?? 0; + const skippedCount = interaction.result?.skippedClientKeys?.length ?? 0; pushToast({ - title: "Suggested tasks accepted", + title: skippedCount > 0 + ? `Accepted ${createdCount} draft${createdCount === 1 ? "" : "s"} and skipped ${skippedCount}` + : "Suggested tasks accepted", tone: "success", }); }, @@ -2336,8 +2348,11 @@ export function IssueDetail() { const handleInterruptQueuedRun = useCallback(async (runId: string) => { await interruptQueuedComment.mutateAsync(runId); }, [interruptQueuedComment]); - const handleAcceptInteraction = useCallback(async (interaction: SuggestTasksInteraction) => { - await acceptInteraction.mutateAsync(interaction); + const handleAcceptInteraction = useCallback(async ( + interaction: SuggestTasksInteraction, + selectedClientKeys?: string[], + ) => { + await acceptInteraction.mutateAsync({ interaction, selectedClientKeys }); }, [acceptInteraction]); const handleRejectInteraction = useCallback(async (interaction: SuggestTasksInteraction, reason?: string) => { await rejectInteraction.mutateAsync({ interaction, reason }); diff --git a/ui/storybook/stories/issue-thread-interactions.stories.tsx b/ui/storybook/stories/issue-thread-interactions.stories.tsx index 580275bdc91..7f97746f18c 100644 --- a/ui/storybook/stories/issue-thread-interactions.stories.tsx +++ b/ui/storybook/stories/issue-thread-interactions.stories.tsx @@ -89,7 +89,18 @@ function InteractiveSuggestedTasksCard() { agentMap={storybookAgentMap} currentUserId={issueThreadInteractionFixtureMeta.currentUserId} userLabelMap={boardUserLabels} - onAcceptInteraction={() => setInteraction(acceptedSuggestedTasksInteraction)} + onAcceptInteraction={(_interaction, selectedClientKeys) => + setInteraction({ + ...acceptedSuggestedTasksInteraction, + result: { + version: 1, + createdTasks: (acceptedSuggestedTasksInteraction.result?.createdTasks ?? []).filter((task) => + selectedClientKeys?.includes(task.clientKey) ?? true), + skippedClientKeys: pendingSuggestedTasksInteraction.payload.tasks + .map((task) => task.clientKey) + .filter((clientKey) => !(selectedClientKeys?.includes(clientKey) ?? true)), + }, + })} onRejectInteraction={(_interaction, reason) => setInteraction({ ...rejectedSuggestedTasksInteraction,