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),