mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Add selective suggested-task acceptance
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
5cf5a0d060
commit
4999dedeea
14 files changed
+543
-24
No files matched your search
@@ -307,6 +307,7 @@ export interface SuggestTasksResultCreatedTask {
|
||||
export interface SuggestTasksResult {
|
||||
version: 1;
|
||||
createdTasks?: SuggestTasksResultCreatedTask[];
|
||||
skippedClientKeys?: string[];
|
||||
rejectionReason?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user