From 4a641d0980fdaffc4c4e805f6f0a8809eb5702f2 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Tue, 13 Oct 2026 00:56:15 -0500
Subject: [PATCH] fix(ui): default human task replies to the latest assigning
agent (#15594)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Tasks let agents ask a person for input.
> - The reply composer needs a useful default recipient.
> - A recent comment does not identify the agent that assigned the task.
> - The old fallback can select the person who is writing the reply.
> - This change uses the latest agent assignment and keeps manual
choices.
## Linked Issues or Issue Description
**What happened?**
When an agent assigns a task to a person, the reply composer can select
that person or a different commenter. A data refresh can also replace a
manual selection.
**Expected behavior**
For a task assigned to the current person, select the latest agent that
assigned it to them. If no such assignment exists, use the latest other
commenter. If neither exists, leave the recipient empty. Keep explicit
choices during refreshes.
**Steps to reproduce**
1. Have agent Alpha assign a task to a person.
2. Assign it to agent Beta, then have Beta assign it to the same person.
3. Open the task as that person.
4. Check that the reply composer selects Beta.
5. Select another recipient. Refresh task data. Check that the selection
stays.
Related work: #15091 shares task composer controls. This change fixes
recipient defaults.
## What Changed
- Read real assignment changes from REST and native runner task
activity, in time order.
- Record human assignee fields in native create and reassign activity.
- Ignore unchanged assignment fields and assignments to other people.
- Use the assigning agent before comment hints for human-owned tasks.
- Connect the suggestion to current and classic task composers and
pipeline replies.
- Keep manual selections when suggestions change.
- Add regression tests and document the fallback.
- Use tasks with no assignee in the attachment receipt tests. This keeps
the tests on the comment route. Dismiss announcements through the real
UI.
- Record human and agent assignees in REST creation receipts. Include
child creation in the assignment history resolver.
## Verification
- Focused tests cover assignment order, creation, other recipients,
unchanged fields, fallback, and manual selections.
- Browser fixture uses the real composer and assignment resolver. It
selects Beta and keeps a manual human selection after history refresh.
- Passed: 223 focused tests across four suites.
- Passed: `pnpm --filter @paperclipai/ui typecheck`.
- Passed: `pnpm --filter @paperclipai/ui build`.
- Passed: `pnpm check:token-gates`.
- The initial CI run passed full build, typecheck, Rust, unit tests and
seven E2E shards. The attachment shard exposed tests that relied on the
old self-selection default. The tests now select the recipient
explicitly.
- Native assignment regression tests and resolver tests pass: 40 tests.
- Attachment E2E suite: 10 passed; one page-load timing failure passed
on an isolated rerun. All 11 cases passed across the suite and rerun.
- Previous full CI passed at cc7ed05. New CI must pass on the current
head.
- Latest local checks: 16 resolver tests and 7 workspace policy tests
pass. REST creation audit tests cover both creation routes.
- The latest browser check could not start. The UI build exceeded the
local memory limit (exit 137). Full typecheck, tests, build, and browser
checks will run in CI.
## Risks
- Old activity rows without previous assignment values cannot prove a
reassignment. They use the comment fallback.
- An empty fallback requires the person to choose a recipient or use the
existing no-assignee flow.
- No database or API schema changes.
## Model Used
OpenAI Codex, GPT-6. The session does not expose an exact model build or
context window. Used code editing, shell tools, tests, and browser
verification.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
Final validation at f83f2f6: 55 checks passed. Two optional Storybook
checks were skipped. All browser shards passed. Both review threads are
resolved. Classic attachment tests use the real no-assignee
confirmation.
---
doc/PRODUCT.md | 5 ++
...e-comment-attribution-audit-routes.test.ts | 22 ++++++
server/src/routes/issues.ts | 4 +-
.../paperclip-runner-tool-authority.ts | 5 +-
.../native-runtime/task-reassignment.test.ts | 20 ++++-
tests/e2e/board-attachment-receipts.spec.ts | 32 +++++---
ui/src/components/CommentThread.test.tsx | 37 +++++++++-
ui/src/components/CommentThread.tsx | 17 ++++-
ui/src/components/IssueChatThread.test.tsx | 36 ++++++++-
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 | 73 ++++++++++++++++++-
ui/src/lib/assignees.ts | 29 +++++++-
ui/src/pages/IssueDetail.tsx | 10 ++-
ui/src/pages/Pipelines.tsx | 4 +-
17 files changed, 317 insertions(+), 36 deletions(-)
diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md
index e53dcafe77..27b2b1accd 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/server/src/__tests__/issue-comment-attribution-audit-routes.test.ts b/server/src/__tests__/issue-comment-attribution-audit-routes.test.ts
index 36d17da5c5..860af32ab4 100644
--- a/server/src/__tests__/issue-comment-attribution-audit-routes.test.ts
+++ b/server/src/__tests__/issue-comment-attribution-audit-routes.test.ts
@@ -78,6 +78,7 @@ describeEmbeddedPostgres("issue comment attribution and patch audit routes", ()
async function seed() {
const company = await db.insert(companies).values({
name: "Attribution Company",
+ issueCounter: 2,
issuePrefix: `AT${randomUUID().replaceAll("-", "").slice(0, 6).toUpperCase()}`,
}).returning().then((rows) => rows[0]!);
const [actorAgent, targetAgent] = await db.insert(agents).values(["Actor", "Target"].map((name) => ({
@@ -162,6 +163,27 @@ describeEmbeddedPostgres("issue comment attribution and patch audit routes", ()
};
}
+ it("records the human recipient for REST task and child creation", async () => {
+ const fixture = await seed();
+ const app = await createApp(db, agentActor(fixture));
+ for (const [path, action] of [
+ [`/api/companies/${fixture.company.id}/issues`, "issue.created"],
+ [`/api/issues/${fixture.issue.id}/children`, "issue.child_created"],
+ ]) {
+ const response = await request(app).post(path!).send({
+ title: "Task for the human", status: "todo", assigneeUserId: fixture.responsibleUserId,
+ });
+ expect(response.status, JSON.stringify(response.body)).toBe(201);
+ const events = await db.select().from(activityLog).where(and(
+ eq(activityLog.entityId, response.body.id), eq(activityLog.action, action!),
+ ));
+ expect(events).toEqual([expect.objectContaining({
+ actorType: "agent", actorId: fixture.actorAgent.id,
+ details: expect.objectContaining({ assigneeAgentId: null, assigneeUserId: fixture.responsibleUserId }),
+ })]);
+ }
+ });
+
it("attributes foreign-issue agent comments and emits complete agent and human PATCH receipts", async () => {
const fixture = await seed();
const agentApp = await createApp(db, agentActor(fixture));
diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts
index fba3795594..66b7d0460b 100644
--- a/server/src/routes/issues.ts
+++ b/server/src/routes/issues.ts
@@ -662,7 +662,7 @@ const attachmentArtifactMetadataInputSchema = z
.passthrough();
function buildCreateIssueActivityStatusDetails(
- issue: { assigneeAgentId: string | null; status: string },
+ issue: { assigneeAgentId: string | null; assigneeUserId: string | null; status: string },
res: Response,
) {
const statusDefault = res.locals.createIssueStatusDefault as
@@ -670,6 +670,8 @@ function buildCreateIssueActivityStatusDetails(
const assignmentWakeSkipped =
!issue.assigneeAgentId || issue.status === "backlog";
return {
+ assigneeAgentId: issue.assigneeAgentId,
+ assigneeUserId: issue.assigneeUserId,
status: issue.status,
statusDefaulted: statusDefault?.defaulted ?? false,
statusDefaultReason: statusDefault?.reason ?? "explicit",
diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts
index e8f2e574f4..058500e8d8 100644
--- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts
+++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts
@@ -1286,7 +1286,7 @@ export class PaperclipRunnerToolAuthority {
agentId: this.binding.agentId, runId: this.binding.runId, issueId: child.id,
action: "issue.created", entityType: "issue", entityId: child.id,
details: { identifier: child.identifier, title: child.title, parentId: child.parentId,
- assigneeAgentId: child.assigneeAgentId, status: childStatus, source: "paperclip_runner_protocol" },
+ assigneeAgentId: child.assigneeAgentId, assigneeUserId: child.assigneeUserId, status: childStatus, source: "paperclip_runner_protocol" },
});
publication = activity.publication;
}
@@ -1486,7 +1486,8 @@ export class PaperclipRunnerToolAuthority {
agentId: this.binding.agentId, runId: this.binding.runId, issueId: taskId,
action: "issue.reassigned", entityType: "issue", entityId: taskId,
details: { source: "paperclip_runner_protocol", reassignmentKey: durableKey, inputFingerprint: fingerprint,
- previousAssigneeAgentId: target.assigneeAgentId, assigneeAgentId, reason, changed, receipt },
+ previousAssigneeAgentId: target.assigneeAgentId, previousAssigneeUserId: target.assigneeUserId,
+ assigneeAgentId, assigneeUserId, reason, changed, receipt },
});
publication = activity.publication;
return receipt;
diff --git a/server/src/services/native-runtime/task-reassignment.test.ts b/server/src/services/native-runtime/task-reassignment.test.ts
index f28276130d..8f3d86b063 100644
--- a/server/src/services/native-runtime/task-reassignment.test.ts
+++ b/server/src/services/native-runtime/task-reassignment.test.ts
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { eq, and } from "drizzle-orm";
-import { activityLog, agents, companies, createDb, heartbeatRuns, issues, issueComments } from "@paperclipai/db";
+import { activityLog, agents, authUsers, companyMemberships, companies, createDb, heartbeatRuns, issues, issueComments } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { issueService } from "../issues.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
@@ -49,6 +49,24 @@ describe("runner task reassignment", () => {
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, f.targetId))).toHaveLength(1);
});
+ it("records human assignment fields for native creation and reassignment", async () => {
+ const f = await fixture();
+ const person = randomUUID();
+ await db.insert(authUsers).values({ id: person, name: "Reviewer", email: `${person}@example.test`, createdAt: new Date(), updatedAt: new Date() });
+ await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: person, status: "active", membershipRole: "operator" });
+ await f.authority.execute({ ...f.call, arguments: { ...f.call.arguments, assigneeActorId: null, assigneeUserId: person } });
+ const [reassigned] = await db.select().from(activityLog).where(and(eq(activityLog.entityId, f.targetId), eq(activityLog.action, "issue.reassigned")));
+ expect(reassigned).toMatchObject({ actorType: "agent", actorId: f.agentId, details: {
+ previousAssigneeAgentId: f.agentId, previousAssigneeUserId: null,
+ assigneeAgentId: null, assigneeUserId: person, changed: true,
+ } });
+ const created = await f.authority.execute({ tool: "create_task", callId: "human-child", arguments: {
+ title: "Review result", assigneeUserId: person, idempotencyKey: "human-child",
+ } }) as { task: { id: string } };
+ const [creation] = await db.select().from(activityLog).where(and(eq(activityLog.entityId, created.task.id), eq(activityLog.action, "issue.created")));
+ expect(creation).toMatchObject({ actorType: "agent", actorId: f.agentId, details: { assigneeAgentId: null, assigneeUserId: person } });
+ });
+
it.each(["blocked", "backlog"])("preserves %s and does not wake work prematurely", async status => {
const f = await fixture(status);
await f.authority.execute(f.call);
diff --git a/tests/e2e/board-attachment-receipts.spec.ts b/tests/e2e/board-attachment-receipts.spec.ts
index 649758e26e..da87912cf8 100644
--- a/tests/e2e/board-attachment-receipts.spec.ts
+++ b/tests/e2e/board-attachment-receipts.spec.ts
@@ -9,7 +9,7 @@ import {
// Real local Board UI, upload storage, comment HTTP routes, and disposable DB.
// Only the interface flag and explicit transport/upload rejection faults are mocked.
-// Tasks belong to the Board user: no agent, runner, or provider is contacted.
+// Tasks have no recipient: no agent, runner, or provider is contacted.
type Attachment = {
id: string;
issueCommentId: string | null;
@@ -44,7 +44,9 @@ async function setup(page: Page, request: APIRequestContext, classic: boolean) {
data: {
title: "Inspect the newly uploaded Board files",
status: "todo",
- assigneeUserId: "local-board",
+ // Keep attachment sends on the comment route. A task assigned to the
+ // current human now suggests an empty recipient and sends a reassignment.
+ assigneeUserId: null,
},
}),
);
@@ -78,6 +80,14 @@ async function setup(page: Page, request: APIRequestContext, classic: boolean) {
composer,
editor,
send,
+ sendComment: async () => {
+ await send.click();
+ if (classic) {
+ const confirm = page.getByTestId("issue-chat-no-assignee-send-anyway");
+ await expect(confirm).toBeVisible();
+ await confirm.click();
+ }
+ },
attachments: () =>
request
.get(`/api/issues/${issue.id}/attachments`)
@@ -183,7 +193,7 @@ for (const classic of [false, true]) {
req.method() === "POST" &&
new URL(req.url()).pathname.endsWith("/comments"),
);
- await fixture.send.click();
+ await fixture.sendComment();
expect((await outbound).postDataJSON().attachmentIds.sort()).toEqual(
receipts.map((row) => row.id).sort(),
);
@@ -244,7 +254,7 @@ for (const classic of [false, true]) {
accepted = true;
await route.abort("connectionreset");
});
- await fixture.send.click();
+ await fixture.sendComment();
await expect.poll(() => accepted).toBe(true);
expect(await fixture.comments()).toHaveLength(1);
expect(acceptedRequestId).toEqual(expect.any(String));
@@ -276,7 +286,7 @@ for (const classic of [false, true]) {
await fixture.editor.fill(
"A deliberately new comment after the original receipt settled.",
);
- await fixture.send.click();
+ await fixture.sendComment();
await expect.poll(async () => (await fixture.comments()).length).toBe(2);
});
@@ -298,14 +308,14 @@ for (const classic of [false, true]) {
body: JSON.stringify({ error: "Fixture policy rejected this attempt" }),
});
});
- await fixture.send.click();
+ await fixture.sendComment();
await expect(fixture.editor).toContainText("Known rejection,");
await expect(fixture.send).toBeEnabled();
expect(await fixture.comments()).toHaveLength(0);
await page.reload();
await expect(fixture.editor).toContainText("Known rejection,");
await expect(fixture.composer.getByRole("alert")).toHaveCount(0);
- await fixture.send.click();
+ await fixture.sendComment();
await expect.poll(async () => (await fixture.comments()).length).toBe(1);
expect(
(await fixture.attachments()).find((row) => row.id === receipt.id)
@@ -339,7 +349,7 @@ for (const classic of [false, true]) {
await route.fulfill({ response }).catch(() => {});
});
try {
- await fixture.send.click();
+ await fixture.sendComment();
await expect.poll(() => accepted).toBe(true);
expect(await fixture.comments()).toHaveLength(1);
await fixture.editor.fill("A newer draft written while delivery was pending.");
@@ -354,7 +364,7 @@ for (const classic of [false, true]) {
expect(await fixture.comments()).toHaveLength(1);
expect(acceptedRequestId).toEqual(expect.any(String));
expect((await fixture.comments())[0]!.clientRequestId).toBe(acceptedRequestId);
- await fixture.send.click();
+ await fixture.sendComment();
await expect.poll(async () => (await fixture.comments()).length).toBe(2);
expect(attempts).toBe(2);
expect((await fixture.comments()).map((comment) => comment.body).sort()).toEqual([
@@ -412,7 +422,7 @@ for (const classic of [false, true])
req.method() === "POST" &&
new URL(req.url()).pathname.endsWith("/comments"),
);
- await fixture.send.click();
+ await fixture.sendComment();
expect((await outbound).postDataJSON().attachmentIds).toBeUndefined();
await expect.poll(async () => (await fixture.comments()).length).toBe(1);
expect((await fixture.comments())[0]!.body).not.toContain(
@@ -450,7 +460,7 @@ test("legacy failed upload can be removed before sending the retained text", asy
});
await expect(remove).toBeVisible();
await remove.click();
- await fixture.send.click();
+ await fixture.sendComment();
await expect.poll(async () => (await fixture.comments()).length).toBe(1);
expect((await fixture.comments())[0]!.body).toBe(
"Keep this text after removing the failed file.",
diff --git a/ui/src/components/CommentThread.test.tsx b/ui/src/components/CommentThread.test.tsx
index 427d5f62ea..a6f05717e4 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 328bf906ca..287f350e17 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 5650ba0e7e..6f6b2bc2eb 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,33 @@ 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} enableLiveTranscriptPolling={false} />
+ ,
+ ));
+ 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());
+ });
function renderIssueThread(root: ReturnType, props: Partial> = {}) {
root.render(
diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx
index 2eb81a46ef..695d9a168e 100644
--- a/ui/src/components/IssueChatThread.tsx
+++ b/ui/src/components/IssueChatThread.tsx
@@ -4763,6 +4763,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,
);
@@ -4919,8 +4925,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);
@@ -5065,6 +5075,7 @@ const IssueChatComposer = forwardRef<
setComposerAttachments((current) =>
current.filter((item) => !submittedAttachmentKeys.has(item.id)),
);
+ manualAssigneeRef.current = false;
setReassignTarget(effectiveSuggestedAssigneeValue);
setRunSettings(null);
} catch (error) {
@@ -5606,7 +5617,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;
@@ -5622,7 +5633,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 c1425f56e7..de97aa93eb 100644
--- a/ui/src/components/TaskChatThread.tsx
+++ b/ui/src/components/TaskChatThread.tsx
@@ -527,6 +527,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
conversationMode,
reassignOptions,
currentAssigneeValue,
+ suggestedAssigneeValue,
assigneeAdapterOverrides,
issueStatus,
issueAssigneeAgentId = null,
@@ -604,12 +605,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(() => {
@@ -3240,6 +3244,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 769a4d4c50..be889270e0 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 66b4f3f1bc..36f912816e 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,71 @@ 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");
+ });
+});
+
+
+it("prefers a newer native reassignment over a REST assignment and ignores native no-ops", () => {
+ const event = (actorId: string, action: string, createdAt: string, details: Record): ActivityEvent => ({
+ id: actorId, companyId: "company", entityType: "issue", entityId: "task", agentId: actorId, runId: null,
+ actorType: "agent", actorId, action, createdAt: new Date(createdAt), details,
+ });
+ const history = [
+ event("alpha", "issue.updated", "2026-01-01", { assigneeUserId: "me", _previous: { assigneeUserId: null } }),
+ event("beta", "issue.reassigned", "2026-02-01", { assigneeUserId: "me", previousAssigneeUserId: null, changed: true }),
+ event("no-op", "issue.reassigned", "2026-03-01", { assigneeUserId: "me", previousAssigneeUserId: "me", changed: false }),
+ ];
+ expect(suggestedCommentAssigneeValue({ assigneeUserId: "me" }, [], "me", undefined, history)).toBe("agent:beta");
+});
+
+
+it.each(["issue.created", "issue.child_created"])("uses a REST %s receipt assigned to the current human", (action) => {
+ const event: ActivityEvent = {
+ id: "creation", companyId: "company", entityType: "issue", entityId: "task",
+ agentId: "creator", runId: null, actorType: "agent", actorId: "creator", action,
+ createdAt: new Date("2026-02-01"),
+ details: { title: "Task", identifier: "PAP-1", status: "todo", assigneeAgentId: null, assigneeUserId: "me" },
+ };
+ expect(suggestedCommentAssigneeValue({ assigneeUserId: "me" }, [{ authorAgentId: "commenter" }], "me", undefined, [event])).toBe("agent:creator");
+ expect(suggestedCommentAssigneeValue({ assigneeUserId: "other" }, [], "other", undefined, [event])).toBe("");
+});
diff --git a/ui/src/lib/assignees.ts b/ui/src/lib/assignees.ts
index 2e45490cd9..1992442cc9 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,32 @@ 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" || event.action === "issue.child_created") return true;
+ if (event.action === "issue.reassigned") {
+ return details.changed === true
+ && Object.hasOwn(details, "previousAssigneeUserId")
+ && details.previousAssigneeUserId !== currentUserId;
+ }
+ 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 +70,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 b7ebb693a9..af019ce5e2 100644
--- a/ui/src/pages/IssueDetail.tsx
+++ b/ui/src/pages/IssueDetail.tsx
@@ -3850,14 +3850,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 55b4b16271..1aa8b6ea62 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),