fix(ui): default human task replies to latest assigning agent

This commit is contained in:
cryppadotta committed 2026-10-08 17:19:32 +00:00
1 parent 0ac194450a
commit 285d20499c
12 files changed
+219 -21

No files matched your search

+5
View File
@@ -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.
+36 -1
View File
@@ -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);
+14 -3
View File
@@ -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>;
+36 -1
View File
@@ -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,34 @@ function createFileDragEvent(type: string, files: File[]) {
describe("IssueChatThread", () => {
let container: HTMLDivElement;
it("updates automatic suggestions but preserves manual choices until the task changes", () => {
const root = createRoot(container);
const render = (suggestion: string, draftKey = "task-one") => act(() => root.render(
<MemoryRouter>
<IssueChatThread 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);
+15 -4
View File
@@ -4779,6 +4779,12 @@ const IssueChatComposer = forwardRef<
const dragDepthRef = useRef(0);
const effectiveSuggestedAssigneeValue =
suggestedAssigneeValue ?? currentAssigneeValue;
const manualAssigneeRef = useRef(false);
const assigneeDraftKeyRef = useRef(draftKey);
const selectAssignee = (value: string) => {
manualAssigneeRef.current = true;
setReassignTarget(value);
};
const [reassignTarget, setReassignTarget] = useState(
effectiveSuggestedAssigneeValue,
);
@@ -4935,8 +4941,12 @@ const IssueChatComposer = forwardRef<
}, [draftKey]);
useEffect(() => {
setReassignTarget(effectiveSuggestedAssigneeValue);
}, [effectiveSuggestedAssigneeValue]);
if (assigneeDraftKeyRef.current !== draftKey) {
assigneeDraftKeyRef.current = draftKey;
manualAssigneeRef.current = false;
}
if (!manualAssigneeRef.current) setReassignTarget(effectiveSuggestedAssigneeValue);
}, [draftKey, effectiveSuggestedAssigneeValue]);
useEffect(() => {
setPendingWorkMode(resolvedIssueWorkMode);
@@ -5081,6 +5091,7 @@ const IssueChatComposer = forwardRef<
setComposerAttachments((current) =>
current.filter((item) => !submittedAttachmentKeys.has(item.id)),
);
manualAssigneeRef.current = false;
setReassignTarget(effectiveSuggestedAssigneeValue);
setRunSettings(null);
} catch (error) {
@@ -5622,7 +5633,7 @@ const IssueChatComposer = forwardRef<
overrides={assigneeAdapterOverrides}
settings={runSettings}
onSettingsChange={setRunSettings}
onAssigneeChange={setReassignTarget}
onAssigneeChange={selectAssignee}
triggerRef={reassignTriggerRef}
renderAssigneeIdentity={(value) => {
const selected = value.startsWith("agent:") ? agentMap.get(value.slice(6)) : null;
@@ -5638,7 +5649,7 @@ const IssueChatComposer = forwardRef<
noneLabel="No responsible"
searchPlaceholder="Search responsible..."
emptyMessage="No responsible found."
onChange={setReassignTarget}
onChange={selectAssignee}
className="h-8 text-xs"
renderTriggerValue={(option) => {
if (!option)
+11 -6
View File
@@ -524,6 +524,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
conversationMode,
reassignOptions,
currentAssigneeValue,
suggestedAssigneeValue,
assigneeAdapterOverrides,
issueStatus,
issueAssigneeAgentId = null,
@@ -601,12 +602,15 @@ export function TaskChatThread(props: TaskChatThreadProps) {
const [pendingComposerAssignee, setPendingComposerAssignee] = useState<
string | null
>(null);
const effectiveGoalAgentId =
pendingComposerAssignee === null
? issueAssigneeAgentId
: pendingComposerAssignee.startsWith("agent:")
? pendingComposerAssignee.slice("agent:".length) || null
: null;
const composerSuggestedAssignee = currentUserId && currentAssigneeValue === `user:${currentUserId}`
? suggestedAssigneeValue
: undefined;
const effectiveComposerAssignee = pendingComposerAssignee ?? composerSuggestedAssignee;
const effectiveGoalAgentId = effectiveComposerAssignee === undefined
? issueAssigneeAgentId
: effectiveComposerAssignee.startsWith("agent:")
? effectiveComposerAssignee.slice("agent:".length) || null
: null;
const runnerGoal = useRunnerGoalControl(issueId, effectiveGoalAgentId);
useEffect(() => {
@@ -3203,6 +3207,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
modelAgents={agentMap}
userProfileMap={userProfileMap}
currentAssigneeValue={currentAssigneeValue}
suggestedAssigneeValue={composerSuggestedAssignee}
companyId={companyId}
assigneeAdapterOverrides={assigneeAdapterOverrides}
onPendingAssigneeChange={setPendingComposerAssignee}
@@ -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 =
+45 -2
View File
@@ -1,3 +1,4 @@
import type { ActivityEvent } from "@paperclipai/shared";
import { describe, expect, it } from "vitest";
import {
assigneeValueFromSelection,
@@ -72,14 +73,14 @@ describe("assignee selection helpers", () => {
).toBe("agent:agent-123");
});
it("falls back to the actual assignee when there is no better commenter hint", () => {
it("leaves the recipient empty instead of defaulting to the current human", () => {
expect(
suggestedCommentAssigneeValue(
{ assigneeUserId: "board-user" },
[{ authorUserId: "board-user" }],
"board-user",
),
).toBe("user:board-user");
).toBe("");
});
it("skips the current agent when choosing a suggested commenter assignee", () => {
@@ -97,3 +98,45 @@ describe("assignee selection helpers", () => {
).toBe("agent:agent-123");
});
});
describe("assignment history suggestions", () => {
const assignment = (actorId: string, date: string, details: Record<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");
});
});
+23 -1
View File
@@ -1,3 +1,5 @@
import type { ActivityEvent } from "@paperclipai/shared";
export interface AssigneeSelection {
assigneeAgentId: string | null;
assigneeUserId: string | null;
@@ -30,7 +32,27 @@ export function suggestedCommentAssigneeValue(
comments: CommentAssigneeSuggestionComment[] | null | undefined,
currentUserId: string | null | undefined,
currentAgentId?: string | null | undefined,
activity: readonly ActivityEvent[] = [],
): string {
const assignedToCurrentUser = Boolean(currentUserId && issue.assigneeUserId === currentUserId);
if (assignedToCurrentUser) {
const assignment = activity
.filter((event) => {
if (event.actorType !== "agent" || !event.actorId || event.actorId === currentAgentId) return false;
const details = event.details;
if (!details || details.assigneeUserId !== currentUserId) return false;
if (event.action === "issue.created") return true;
if (event.action !== "issue.updated") return false;
const previous = details._previous;
// Updates can resend unchanged fields. Only a real assignment counts.
return previous !== null && typeof previous === "object"
&& Object.hasOwn(previous, "assigneeUserId")
&& (previous as Record<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 +65,7 @@ export function suggestedCommentAssigneeValue(
}
}
return assigneeValueFromSelection(issue);
return assignedToCurrentUser ? "" : assigneeValueFromSelection(issue);
}
export function parseAssigneeValue(value: string): AssigneeSelection {
+9 -1
View File
@@ -3841,14 +3841,22 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
[issue],
);
const { data: assignmentActivity } = useQuery({
queryKey: queryKeys.issues.activity(issueId!),
queryFn: () => activityApi.forIssue(issueId!),
enabled: Boolean(issueId && currentUserId && issue?.assigneeUserId === currentUserId),
});
const suggestedAssigneeValue = useMemo(
() =>
suggestedCommentAssigneeValue(
issue ?? {},
mergeIssueComments(comments ?? [], optimisticComments),
currentUserId,
undefined,
assignmentActivity,
),
[issue, comments, optimisticComments, currentUserId],
[issue, comments, optimisticComments, currentUserId, assignmentActivity],
);
const threadComments = useMemo(
+3 -1
View File
@@ -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),