Add selective suggested-task acceptance

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-21 17:32:27 -05:00
1 parent 5cf5a0d060
commit 4999dedeea
14 files changed
+543 -24

No files matched your search

+1
View File
@@ -307,6 +307,7 @@ export interface SuggestTasksResultCreatedTask {
export interface SuggestTasksResult {
version: 1;
createdTasks?: SuggestTasksResultCreatedTask[];
skippedClientKeys?: string[];
rejectionReason?: string | null;
}
+17 -1
View File
@@ -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<typeof createIssueThreadInteractionSchema>;
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<string>();
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<typeof acceptIssueThreadInteractionSchema>;
export const rejectIssueThreadInteractionSchema = z.object({
@@ -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,
@@ -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();
+5 -1
View File
@@ -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,
},
});
@@ -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<SuggestTasksInteraction["pa
return ordered;
}
function resolveSelectedSuggestedTasks(args: {
interaction: SuggestTasksInteraction;
selectedClientKeys?: AcceptIssueThreadInteraction["selectedClientKeys"];
}) {
const taskByClientKey = new Map(
args.interaction.payload.tasks.map((task) => [task.clientKey, task] as const),
);
const selectedClientKeys = args.selectedClientKeys ?? args.interaction.payload.tasks.map((task) => task.clientKey);
const selectedClientKeySet = new Set<string>();
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,
+6 -2
View File
@@ -106,8 +106,12 @@ export const issuesApi = {
api.get<IssueThreadInteraction[]>(`/issues/${id}/interactions`),
createInteraction: (id: string, data: Record<string, unknown>) =>
api.post<IssueThreadInteraction>(`/issues/${id}/interactions`, data),
acceptInteraction: (id: string, interactionId: string) =>
api.post<SuggestTasksInteraction>(`/issues/${id}/interactions/${interactionId}/accept`, {}),
acceptInteraction: (
id: string,
interactionId: string,
data?: { selectedClientKeys?: string[] },
) =>
api.post<SuggestTasksInteraction>(`/issues/${id}/interactions/${interactionId}/accept`, data ?? {}),
rejectInteraction: (id: string, interactionId: string, reason?: string) =>
api.post<SuggestTasksInteraction>(`/issues/${id}/interactions/${interactionId}/reject`, reason ? { reason } : {}),
respondToInteraction: (
@@ -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(
<MemoryRouter>
<IssueChatThread
comments={[]}
interactions={[createSuggestedTasksInteraction({
payload: {
version: 1,
tasks: [
{
clientKey: "root",
title: "Root task",
},
{
clientKey: "child",
parentClientKey: "root",
title: "Child task",
},
],
},
})]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
onAcceptInteraction={onAcceptInteraction}
showComposer={false}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
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(() => {
+8 -2
View File
@@ -122,7 +122,10 @@ interface IssueChatMessageContext {
onCancelQueued?: (commentId: string) => void;
interruptingQueuedRunId?: string | null;
onImageClick?: (src: string) => void;
onAcceptInteraction?: (interaction: SuggestTasksInteraction) => Promise<void> | void;
onAcceptInteraction?: (
interaction: SuggestTasksInteraction,
selectedClientKeys?: string[],
) => Promise<void> | 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> | void;
onAcceptInteraction?: (
interaction: SuggestTasksInteraction,
selectedClientKeys?: string[],
) => Promise<void> | void;
onRejectInteraction?: (
interaction: SuggestTasksInteraction,
reason?: string,
@@ -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<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
onAcceptInteraction?: (interaction: SuggestTasksInteraction) => Promise<void> | void;
onAcceptInteraction?: (
interaction: SuggestTasksInteraction,
selectedClientKeys?: string[],
) => Promise<void> | void;
onRejectInteraction?: (
interaction: SuggestTasksInteraction,
reason?: string,
@@ -153,6 +158,10 @@ function TaskTreeNode({
currentUserId,
userLabelMap,
depth = 0,
selectedClientKeys,
skippedClientKeys,
showSelection,
onToggleSelection,
}: {
node: SuggestedTaskTreeNode;
createdByClientKey: ReadonlyMap<string, SuggestTasksResultCreatedTask>;
@@ -160,12 +169,18 @@ function TaskTreeNode({
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
depth?: number;
selectedClientKeys?: ReadonlySet<string>;
skippedClientKeys?: ReadonlySet<string>;
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({
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="flex items-start gap-3">
{showSelection ? (
<Checkbox
checked={isSelected}
onCheckedChange={(checked) => onToggleSelection?.(node, checked === true)}
aria-label={`Include ${node.task.title}`}
className="mt-1"
/>
) : null}
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-sky-300/70 bg-sky-100/80 text-sky-900">
<Sparkles className="h-3.5 w-3.5" />
</span>
@@ -212,6 +235,10 @@ function TaskTreeNode({
{createdTask.identifier ?? createdTask.issueId.slice(0, 8)}
<ChevronRight className="h-3 w-3" />
</Link>
) : isSkipped ? (
<span className="inline-flex items-center rounded-full border border-amber-300/70 bg-amber-100/80 px-2.5 py-1 text-[11px] font-medium text-amber-950">
Skipped
</span>
) : null}
</div>
@@ -256,6 +283,10 @@ function TaskTreeNode({
currentUserId={currentUserId}
userLabelMap={userLabelMap}
depth={depth + 1}
selectedClientKeys={selectedClientKeys}
skippedClientKeys={skippedClientKeys}
showSelection={showSelection}
onToggleSelection={onToggleSelection}
/>
))}
</div>
@@ -276,7 +307,10 @@ function SuggestTasksCard({
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
onAcceptInteraction?: (interaction: SuggestTasksInteraction) => Promise<void> | void;
onAcceptInteraction?: (
interaction: SuggestTasksInteraction,
selectedClientKeys?: string[],
) => Promise<void> | 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<Set<string>>(
() => 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 (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
@@ -352,10 +433,27 @@ function SuggestTasksCard({
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
selectedClientKeys={selectedClientKeys}
skippedClientKeys={skippedClientKeys}
showSelection={interaction.status === "pending"}
onToggleSelection={handleToggleSelection}
/>
))}
</div>
{interaction.status === "accepted" ? (
<div className="rounded-2xl border border-emerald-300/60 bg-emerald-50/85 px-4 py-3 text-sm text-emerald-950">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-700">
Resolution summary
</div>
<p className="mt-1 leading-6">
{skippedCount > 0
? `Created ${createdCount} draft ${createdCount === 1 ? "issue" : "issues"} and skipped ${skippedCount} during review.`
: `Created all ${createdCount} draft ${createdCount === 1 ? "issue" : "issues"}.`}
</p>
</div>
) : null}
{interaction.status === "rejected" && interaction.result?.rejectionReason ? (
<div className="rounded-2xl border border-rose-300/60 bg-rose-50/85 px-4 py-3 text-sm text-rose-950">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-rose-700">
@@ -367,10 +465,23 @@ function SuggestTasksCard({
{interaction.status === "pending" ? (
<div className="rounded-2xl border border-border/70 bg-background/75 p-4">
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>
{selectedCount === totalTasks
? `All ${totalTasks} draft ${totalTasks === 1 ? "issue" : "issues"} selected`
: `${selectedCount} of ${totalTasks} draft ${totalTasks === 1 ? "issue" : "issues"} selected`}
</span>
{selectedCount < totalTasks ? (
<span>
{totalTasks - selectedCount} will be skipped if you accept this interaction.
</span>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
disabled={!onAcceptInteraction || working !== null}
disabled={!onAcceptInteraction || working !== null || selectedCount === 0}
onClick={() => void handleAccept()}
>
{working === "accept" ? (
@@ -379,7 +490,7 @@ function SuggestTasksCard({
Accepting...
</>
) : (
"Accept drafts"
selectedCount === totalTasks ? "Accept drafts" : "Accept selected drafts"
)}
</Button>
<Button
@@ -390,6 +501,16 @@ function SuggestTasksCard({
>
Reject
</Button>
{selectedCount < totalTasks ? (
<Button
size="sm"
variant="ghost"
disabled={working !== null}
onClick={() => setSelectedClientKeys(new Set(interaction.payload.tasks.map((task) => task.clientKey)))}
>
Reset selection
</Button>
) : null}
</div>
{rejecting ? (
@@ -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", () => {
+21
View File
@@ -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[];
+21 -6
View File
@@ -542,7 +542,10 @@ type IssueDetailChatTabProps = {
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) => Promise<void>;
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 });
@@ -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,