From 285d20499c681ac1e24ed31f84a468cd2b11cab6 Mon Sep 17 00:00:00 2001 From: cryppadotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:19:32 +0000 Subject: [PATCH] fix(ui): default human task replies to latest assigning agent --- doc/PRODUCT.md | 5 ++ ui/src/components/CommentThread.test.tsx | 37 ++++++++++++++- ui/src/components/CommentThread.tsx | 17 +++++-- ui/src/components/IssueChatThread.test.tsx | 37 ++++++++++++++- ui/src/components/IssueChatThread.tsx | 19 ++++++-- ui/src/components/TaskChatThread.tsx | 17 ++++--- .../task-chat/TaskChatComposer.test.tsx | 19 ++++++++ .../components/task-chat/TaskChatComposer.tsx | 4 +- ui/src/lib/assignees.test.ts | 47 ++++++++++++++++++- ui/src/lib/assignees.ts | 24 +++++++++- ui/src/pages/IssueDetail.tsx | 10 +++- ui/src/pages/Pipelines.tsx | 4 +- 12 files changed, 219 insertions(+), 21 deletions(-) diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index e53dcafe773..27b2b1accd6 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -67,6 +67,11 @@ Tasks have parentage. Every task exists in service of a parent task, all the way The current issue model includes stable issue identifiers, parent/sub-issues, blockers, a single assignee, comments, issue documents, attachments and work products, and review/approval handoffs. That structure keeps work inspectable by both the board and agents while still allowing agents to decompose work into smaller tasks. +When a task is assigned to the current human, the message composer defaults to +the latest agent that assigned it to them. If no assignment is available, it uses +the latest other commenter, or leaves the recipient empty. A manual recipient +choice stays selected while task data refreshes. + ### Company Skills and Policy Company skills are shared operating capabilities, not privileged objects by default. Every authenticated agent in a company can create, import, install, edit, update, test, reset, and remove that company's skills unless the company has configured an explicit restriction. diff --git a/ui/src/components/CommentThread.test.tsx b/ui/src/components/CommentThread.test.tsx index 427d5f62ea6..a6f05717e4c 100644 --- a/ui/src/components/CommentThread.test.tsx +++ b/ui/src/components/CommentThread.test.tsx @@ -44,7 +44,14 @@ vi.mock("./MarkdownEditor", () => ({ })); vi.mock("./InlineEntitySelector", () => ({ - InlineEntitySelector: () => null, + InlineEntitySelector: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => ( + + ), })); vi.mock("./ApprovalCard", () => ({ @@ -78,6 +85,34 @@ describe("CommentThread", () => { let writeTextMock: ReturnType Promise>>; let execCommandMock: ReturnType>; + it("updates automatic suggestions but preserves manual choices until the task changes", () => { + const root = createRoot(container); + const render = (suggestion: string, draftKey = "task-one") => act(() => root.render( + + {}} enableReassign + reassignOptions={[{ id: "agent:first", label: "First agent" }]} + currentAssigneeValue="user:me" suggestedAssigneeValue={suggestion} draftKey={draftKey} /> + , + )); + const selector = () => container.querySelector('select[aria-label="Test recipient"]') as HTMLSelectElement; + render("agent:first"); + expect(selector().value).toBe("agent:first"); + render("agent:latest"); + expect(selector().value).toBe("agent:latest"); + for (const manual of ["user:me", ""]) { + act(() => { + selector().value = manual; + selector().dispatchEvent(new Event("change", { bubbles: true })); + }); + render("agent:first"); + render("agent:latest"); + expect(selector().value).toBe(manual); + } + render("agent:first", "task-two"); + expect(selector().value).toBe("agent:first"); + act(() => root.unmount()); + }); + beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 328bf906caa..287f350e173 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -752,6 +752,12 @@ export function CommentThread({ const [submitting, setSubmitting] = useState(false); const [attaching, setAttaching] = useState(false); const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue; + const manualAssigneeRef = useRef(false); + const assigneeDraftKeyRef = useRef(draftKey); + const selectAssignee = (value: string) => { + manualAssigneeRef.current = true; + setReassignTarget(value); + }; const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue); const [highlightCommentId, setHighlightCommentId] = useState(null); const [votingTargetId, setVotingTargetId] = useState(null); @@ -852,8 +858,12 @@ export function CommentThread({ }, []); useEffect(() => { - setReassignTarget(effectiveSuggestedAssigneeValue); - }, [effectiveSuggestedAssigneeValue]); + if (assigneeDraftKeyRef.current !== draftKey) { + assigneeDraftKeyRef.current = draftKey; + manualAssigneeRef.current = false; + } + if (!manualAssigneeRef.current) setReassignTarget(effectiveSuggestedAssigneeValue); + }, [draftKey, effectiveSuggestedAssigneeValue]); // Scroll to comment when URL hash matches #comment-{id} useEffect(() => { @@ -898,6 +908,7 @@ export function CommentThread({ try { await onAdd(submittedBody, reopen, reassignment ?? undefined); if (draftKey) clearDraft(draftKey); + manualAssigneeRef.current = false; setReassignTarget(effectiveSuggestedAssigneeValue); } catch { setBody((current) => @@ -1051,7 +1062,7 @@ export function CommentThread({ noneLabel="No responsible" searchPlaceholder="Search responsible..." emptyMessage="No responsible found." - onChange={setReassignTarget} + onChange={selectAssignee} className="text-xs h-8" renderTriggerValue={(option) => { if (!option) return Responsible; diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 72d4dd8968a..fd9ae8f446c 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -167,7 +167,14 @@ vi.mock("./MarkdownEditor", () => ({ })); vi.mock("./InlineEntitySelector", () => ({ - InlineEntitySelector: () => null, + InlineEntitySelector: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => ( + + ), })); vi.mock("./Identity", () => ({ @@ -375,6 +382,34 @@ function createFileDragEvent(type: string, files: File[]) { describe("IssueChatThread", () => { let container: HTMLDivElement; + it("updates automatic suggestions but preserves manual choices until the task changes", () => { + const root = createRoot(container); + const render = (suggestion: string, draftKey = "task-one") => act(() => root.render( + + {}} enableReassign + reassignOptions={[{ id: "agent:first", label: "First agent" }]} + currentAssigneeValue="user:me" suggestedAssigneeValue={suggestion} draftKey={draftKey} /> + , + )); + const selector = () => container.querySelector('select[aria-label="Test recipient"]') as HTMLSelectElement; + render("agent:first"); + expect(selector().value).toBe("agent:first"); + render("agent:latest"); + expect(selector().value).toBe("agent:latest"); + for (const manual of ["user:me", ""]) { + act(() => { + selector().value = manual; + selector().dispatchEvent(new Event("change", { bubbles: true })); + }); + render("agent:first"); + render("agent:latest"); + expect(selector().value).toBe(manual); + } + render("agent:first", "task-two"); + expect(selector().value).toBe("agent:first"); + act(() => root.unmount()); + }); + beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 4a9aef03b1f..cd9355d6533 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -4779,6 +4779,12 @@ const IssueChatComposer = forwardRef< const dragDepthRef = useRef(0); const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue; + const manualAssigneeRef = useRef(false); + const assigneeDraftKeyRef = useRef(draftKey); + const selectAssignee = (value: string) => { + manualAssigneeRef.current = true; + setReassignTarget(value); + }; const [reassignTarget, setReassignTarget] = useState( effectiveSuggestedAssigneeValue, ); @@ -4935,8 +4941,12 @@ const IssueChatComposer = forwardRef< }, [draftKey]); useEffect(() => { - setReassignTarget(effectiveSuggestedAssigneeValue); - }, [effectiveSuggestedAssigneeValue]); + if (assigneeDraftKeyRef.current !== draftKey) { + assigneeDraftKeyRef.current = draftKey; + manualAssigneeRef.current = false; + } + if (!manualAssigneeRef.current) setReassignTarget(effectiveSuggestedAssigneeValue); + }, [draftKey, effectiveSuggestedAssigneeValue]); useEffect(() => { setPendingWorkMode(resolvedIssueWorkMode); @@ -5081,6 +5091,7 @@ const IssueChatComposer = forwardRef< setComposerAttachments((current) => current.filter((item) => !submittedAttachmentKeys.has(item.id)), ); + manualAssigneeRef.current = false; setReassignTarget(effectiveSuggestedAssigneeValue); setRunSettings(null); } catch (error) { @@ -5622,7 +5633,7 @@ const IssueChatComposer = forwardRef< overrides={assigneeAdapterOverrides} settings={runSettings} onSettingsChange={setRunSettings} - onAssigneeChange={setReassignTarget} + onAssigneeChange={selectAssignee} triggerRef={reassignTriggerRef} renderAssigneeIdentity={(value) => { const selected = value.startsWith("agent:") ? agentMap.get(value.slice(6)) : null; @@ -5638,7 +5649,7 @@ const IssueChatComposer = forwardRef< noneLabel="No responsible" searchPlaceholder="Search responsible..." emptyMessage="No responsible found." - onChange={setReassignTarget} + onChange={selectAssignee} className="h-8 text-xs" renderTriggerValue={(option) => { if (!option) diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index c11ae204e86..f0480214932 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -524,6 +524,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { conversationMode, reassignOptions, currentAssigneeValue, + suggestedAssigneeValue, assigneeAdapterOverrides, issueStatus, issueAssigneeAgentId = null, @@ -601,12 +602,15 @@ export function TaskChatThread(props: TaskChatThreadProps) { const [pendingComposerAssignee, setPendingComposerAssignee] = useState< string | null >(null); - const effectiveGoalAgentId = - pendingComposerAssignee === null - ? issueAssigneeAgentId - : pendingComposerAssignee.startsWith("agent:") - ? pendingComposerAssignee.slice("agent:".length) || null - : null; + const composerSuggestedAssignee = currentUserId && currentAssigneeValue === `user:${currentUserId}` + ? suggestedAssigneeValue + : undefined; + const effectiveComposerAssignee = pendingComposerAssignee ?? composerSuggestedAssignee; + const effectiveGoalAgentId = effectiveComposerAssignee === undefined + ? issueAssigneeAgentId + : effectiveComposerAssignee.startsWith("agent:") + ? effectiveComposerAssignee.slice("agent:".length) || null + : null; const runnerGoal = useRunnerGoalControl(issueId, effectiveGoalAgentId); useEffect(() => { @@ -3203,6 +3207,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { modelAgents={agentMap} userProfileMap={userProfileMap} currentAssigneeValue={currentAssigneeValue} + suggestedAssigneeValue={composerSuggestedAssignee} companyId={companyId} assigneeAdapterOverrides={assigneeAdapterOverrides} onPendingAssigneeChange={setPendingComposerAssignee} diff --git a/ui/src/components/task-chat/TaskChatComposer.test.tsx b/ui/src/components/task-chat/TaskChatComposer.test.tsx index 769a4d4c500..be889270e03 100644 --- a/ui/src/components/task-chat/TaskChatComposer.test.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.test.tsx @@ -1701,6 +1701,25 @@ describe("TaskChatComposer", () => { ).toBe("agent:a2"); }); + it("uses assignment suggestions and retains a manual human choice after refresh", async () => { + const view = (suggestion: string) => ; + render(view("agent:a1")); + const trigger = () => container.querySelector('[data-testid="task-chat-composer-assignee"]')!; + expect(trigger().textContent).toContain("Alpha"); + flushSync(() => root!.render(view("agent:a2"))); + expect(trigger().textContent).toContain("Beta"); + flushSync(() => trigger().click()); + await flushAsync(); + const me = document.querySelector('[data-assignee-identity="user:u1"]')!.closest("button")!; + flushSync(() => me.click()); + await flushAsync(); + flushSync(() => root!.render(view("agent:a1"))); + expect(trigger().textContent).toContain("Me"); + }); + it("shows human avatars in assignee options and after selection", async () => { render( | null; currentAssigneeValue?: string; + suggestedAssigneeValue?: string; onPendingAssigneeChange?: (value: string | null) => void; issueStatus?: string; /** Mobile document-flow host: 16px editor text so iOS doesn't zoom on focus. */ @@ -397,6 +398,7 @@ export function TaskChatComposer({ assigneeAdapterOverrides, userProfileMap, currentAssigneeValue = "", + suggestedAssigneeValue, onPendingAssigneeChange, issueStatus, mobile = false, @@ -638,7 +640,7 @@ export function TaskChatComposer({ const showAssignee = Boolean( enableReassign && reassignOptions && reassignOptions.length > 0, ); - const assigneeValue = creation ? currentAssigneeValue : pendingAssignee ?? currentAssigneeValue; + const assigneeValue = creation ? currentAssigneeValue : pendingAssignee ?? suggestedAssigneeValue ?? currentAssigneeValue; const assigneeLabel = reassignOptions?.find((o) => o.id === assigneeValue)?.label ?? "Unassigned"; const assigneeName = diff --git a/ui/src/lib/assignees.test.ts b/ui/src/lib/assignees.test.ts index 66b4f3f1bc3..ef9f8059704 100644 --- a/ui/src/lib/assignees.test.ts +++ b/ui/src/lib/assignees.test.ts @@ -1,3 +1,4 @@ +import type { ActivityEvent } from "@paperclipai/shared"; import { describe, expect, it } from "vitest"; import { assigneeValueFromSelection, @@ -72,14 +73,14 @@ describe("assignee selection helpers", () => { ).toBe("agent:agent-123"); }); - it("falls back to the actual assignee when there is no better commenter hint", () => { + it("leaves the recipient empty instead of defaulting to the current human", () => { expect( suggestedCommentAssigneeValue( { assigneeUserId: "board-user" }, [{ authorUserId: "board-user" }], "board-user", ), - ).toBe("user:board-user"); + ).toBe(""); }); it("skips the current agent when choosing a suggested commenter assignee", () => { @@ -97,3 +98,45 @@ describe("assignee selection helpers", () => { ).toBe("agent:agent-123"); }); }); + + +describe("assignment history suggestions", () => { + const assignment = (actorId: string, date: string, details: Record = {}): ActivityEvent => ({ + companyId: "company", entityType: "issue", entityId: "task", agentId: actorId, runId: null, + id: actorId, actorType: "agent", actorId, action: "issue.updated", + createdAt: new Date(date), details: { assigneeUserId: "me", _previous: { assigneeUserId: null }, ...details }, + }); + const suggest = (activity: ActivityEvent[]) => suggestedCommentAssigneeValue( + { assigneeUserId: "me" }, [{ authorAgentId: "commenter" }], "me", undefined, activity, + ); + + it("uses the latest assigning actor in either history order, ahead of commenters", () => { + const older = assignment("first", "2026-01-01"); + const newer = assignment("last", "2026-02-01"); + expect(suggest([older, newer])).toBe("agent:last"); + expect(suggest([newer, older])).toBe("agent:last"); + }); + + it("ignores other recipients, human actors, non-assignment events and unchanged fields", () => { + const valid = assignment("assigner", "2026-01-01"); + const invalid = assignment("other", "2026-02-01"); + expect(suggest([ + valid, + { ...invalid, actorType: "user" }, + { ...invalid, action: "issue.comment_added" }, + assignment("other-user", "2026-02-01", { assigneeUserId: "someone-else" }), + assignment("no-op", "2026-02-01", { _previous: { assigneeUserId: "me" } }), + assignment("resent", "2026-02-01", { _previous: { status: "todo" } }), + ])).toBe("agent:assigner"); + }); + + it("includes the agent that created a task assigned to the human", () => { + expect(suggest([{ ...assignment("creator", "2026-01-01"), action: "issue.created" }])).toBe("agent:creator"); + }); + + it("keeps the existing commenter fallback and other task defaults", () => { + expect(suggest([])).toBe("agent:commenter"); + expect(suggestedCommentAssigneeValue({ assigneeAgentId: "owner" }, [], "me")).toBe("agent:owner"); + expect(suggestedCommentAssigneeValue({ assigneeUserId: "someone-else" }, [], "me")).toBe("user:someone-else"); + }); +}); diff --git a/ui/src/lib/assignees.ts b/ui/src/lib/assignees.ts index 2e45490cd98..4cedb90843e 100644 --- a/ui/src/lib/assignees.ts +++ b/ui/src/lib/assignees.ts @@ -1,3 +1,5 @@ +import type { ActivityEvent } from "@paperclipai/shared"; + export interface AssigneeSelection { assigneeAgentId: string | null; assigneeUserId: string | null; @@ -30,7 +32,27 @@ export function suggestedCommentAssigneeValue( comments: CommentAssigneeSuggestionComment[] | null | undefined, currentUserId: string | null | undefined, currentAgentId?: string | null | undefined, + activity: readonly ActivityEvent[] = [], ): string { + const assignedToCurrentUser = Boolean(currentUserId && issue.assigneeUserId === currentUserId); + if (assignedToCurrentUser) { + const assignment = activity + .filter((event) => { + if (event.actorType !== "agent" || !event.actorId || event.actorId === currentAgentId) return false; + const details = event.details; + if (!details || details.assigneeUserId !== currentUserId) return false; + if (event.action === "issue.created") return true; + if (event.action !== "issue.updated") return false; + const previous = details._previous; + // Updates can resend unchanged fields. Only a real assignment counts. + return previous !== null && typeof previous === "object" + && Object.hasOwn(previous, "assigneeUserId") + && (previous as Record).assigneeUserId !== currentUserId; + }) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0]; + if (assignment) return `agent:${assignment.actorId}`; + } + if (comments && comments.length > 0 && (currentUserId || currentAgentId)) { for (let i = comments.length - 1; i >= 0; i--) { const comment = comments[i]; @@ -43,7 +65,7 @@ export function suggestedCommentAssigneeValue( } } - return assigneeValueFromSelection(issue); + return assignedToCurrentUser ? "" : assigneeValueFromSelection(issue); } export function parseAssigneeValue(value: string): AssigneeSelection { diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index ee00bb2c464..982107ea1d1 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -3841,14 +3841,22 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS [issue], ); + const { data: assignmentActivity } = useQuery({ + queryKey: queryKeys.issues.activity(issueId!), + queryFn: () => activityApi.forIssue(issueId!), + enabled: Boolean(issueId && currentUserId && issue?.assigneeUserId === currentUserId), + }); + const suggestedAssigneeValue = useMemo( () => suggestedCommentAssigneeValue( issue ?? {}, mergeIssueComments(comments ?? [], optimisticComments), currentUserId, + undefined, + assignmentActivity, ), - [issue, comments, optimisticComments, currentUserId], + [issue, comments, optimisticComments, currentUserId, assignmentActivity], ); const threadComments = useMemo( diff --git a/ui/src/pages/Pipelines.tsx b/ui/src/pages/Pipelines.tsx index 55b4b16271e..1aa8b6ea62a 100644 --- a/ui/src/pages/Pipelines.tsx +++ b/ui/src/pages/Pipelines.tsx @@ -2177,8 +2177,10 @@ export function PipelineItemDetailView({ pipelineId, caseId }: { pipelineId: str starterAssigneeValue ? parseAssigneeValue(starterAssigneeValue) : activeConversationIssue ?? {}, conversationComments, currentUserId, + undefined, + conversationActivity, ), - [activeConversationIssue, conversationComments, currentUserId, starterAssigneeValue], + [activeConversationIssue, conversationComments, currentUserId, starterAssigneeValue, conversationActivity], ); const conversationRunningRun = useMemo( () => resolveRunningPipelineConversationRun(resolvedConversationActiveRun, resolvedConversationLiveRuns),