mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-20 06:02:18 +02:00
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 atcc7ed05. 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 atf83f2f6: 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.
This commit is contained in:
1 parent
d837b65be8
commit
4a641d0980
17 files changed
+317
-36
No files matched your search
@@ -44,7 +44,14 @@ vi.mock("./MarkdownEditor", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./InlineEntitySelector", () => ({
|
||||
InlineEntitySelector: () => null,
|
||||
InlineEntitySelector: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
|
||||
<select aria-label="Test recipient" value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
<option value="agent:first">First agent</option>
|
||||
<option value="agent:latest">Latest agent</option>
|
||||
<option value="user:me">Me</option>
|
||||
<option value="">No responsible</option>
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./ApprovalCard", () => ({
|
||||
@@ -78,6 +85,34 @@ describe("CommentThread", () => {
|
||||
let writeTextMock: ReturnType<typeof vi.fn<(text: string) => Promise<void>>>;
|
||||
let execCommandMock: ReturnType<typeof vi.fn<typeof document.execCommand>>;
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<CommentThread comments={[]} onAdd={async () => {}} enableReassign
|
||||
reassignOptions={[{ id: "agent:first", label: "First agent" }]}
|
||||
currentAssigneeValue="user:me" suggestedAssigneeValue={suggestion} draftKey={draftKey} />
|
||||
</MemoryRouter>,
|
||||
));
|
||||
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);
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [votingTargetId, setVotingTargetId] = useState<string | null>(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 <span className="text-muted-foreground">Responsible</span>;
|
||||
|
||||
@@ -167,7 +167,14 @@ vi.mock("./MarkdownEditor", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./InlineEntitySelector", () => ({
|
||||
InlineEntitySelector: () => null,
|
||||
InlineEntitySelector: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
|
||||
<select aria-label="Test recipient" value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
<option value="agent:first">First agent</option>
|
||||
<option value="agent:latest">Latest agent</option>
|
||||
<option value="user:me">Me</option>
|
||||
<option value="">No responsible</option>
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread comments={[]} onAdd={async () => {}} enableReassign
|
||||
reassignOptions={[{ id: "agent:first", label: "First agent" }]}
|
||||
currentAssigneeValue="user:me" suggestedAssigneeValue={suggestion} draftKey={draftKey} enableLiveTranscriptPolling={false} />
|
||||
</MemoryRouter>,
|
||||
));
|
||||
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<typeof createRoot>, props: Partial<ComponentProps<typeof IssueChatThread>> = {}) {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => <TaskChatComposer onAdd={vi.fn()} workMode="standard"
|
||||
enableReassign currentAssigneeValue="user:u1" suggestedAssigneeValue={suggestion}
|
||||
reassignOptions={[{ id: "agent:a1", label: "Alpha" }, { id: "agent:a2", label: "Beta" }, { id: "user:u1", label: "Me" }]}
|
||||
agentMap={new Map([["a1", { icon: "bot" }], ["a2", { icon: "bot" }]])} />;
|
||||
render(view("agent:a1"));
|
||||
const trigger = () => container.querySelector<HTMLButtonElement>('[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(
|
||||
<TaskChatComposer
|
||||
|
||||
@@ -142,6 +142,7 @@ interface TaskChatComposerProps {
|
||||
{ label: string; image: string | null }
|
||||
> | 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 =
|
||||
|
||||
@@ -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<string, unknown> = {}): 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<string, unknown>): 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("");
|
||||
});
|
||||
+28
-1
@@ -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<string, unknown>).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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user