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