fix: suggest comment reassignment from recent commenter

This commit is contained in:
dotta
2026-03-20 06:05:05 -05:00
parent ee85028534
commit 4ffa2b15dc
4 changed files with 71 additions and 20 deletions

View File

@@ -46,6 +46,7 @@ interface CommentThreadProps {
enableReassign?: boolean; enableReassign?: boolean;
reassignOptions?: InlineEntityOption[]; reassignOptions?: InlineEntityOption[];
currentAssigneeValue?: string; currentAssigneeValue?: string;
suggestedAssigneeValue?: string;
mentions?: MentionOption[]; mentions?: MentionOption[];
} }
@@ -269,13 +270,15 @@ export function CommentThread({
enableReassign = false, enableReassign = false,
reassignOptions = [], reassignOptions = [],
currentAssigneeValue = "", currentAssigneeValue = "",
suggestedAssigneeValue,
mentions: providedMentions, mentions: providedMentions,
}: CommentThreadProps) { }: CommentThreadProps) {
const [body, setBody] = useState(""); const [body, setBody] = useState("");
const [reopen, setReopen] = useState(true); const [reopen, setReopen] = useState(true);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [attaching, setAttaching] = useState(false); const [attaching, setAttaching] = useState(false);
const [reassignTarget, setReassignTarget] = useState(currentAssigneeValue); const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
const [highlightCommentId, setHighlightCommentId] = useState<string | null>(null); const [highlightCommentId, setHighlightCommentId] = useState<string | null>(null);
const editorRef = useRef<MarkdownEditorRef>(null); const editorRef = useRef<MarkdownEditorRef>(null);
const attachInputRef = useRef<HTMLInputElement | null>(null); const attachInputRef = useRef<HTMLInputElement | null>(null);
@@ -337,8 +340,8 @@ export function CommentThread({
}, []); }, []);
useEffect(() => { useEffect(() => {
setReassignTarget(currentAssigneeValue); setReassignTarget(effectiveSuggestedAssigneeValue);
}, [currentAssigneeValue]); }, [effectiveSuggestedAssigneeValue]);
// Scroll to comment when URL hash matches #comment-{id} // Scroll to comment when URL hash matches #comment-{id}
useEffect(() => { useEffect(() => {
@@ -370,7 +373,7 @@ export function CommentThread({
setBody(""); setBody("");
if (draftKey) clearDraft(draftKey); if (draftKey) clearDraft(draftKey);
setReopen(false); setReopen(false);
setReassignTarget(currentAssigneeValue); setReassignTarget(effectiveSuggestedAssigneeValue);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }

View File

@@ -4,6 +4,7 @@ import {
currentUserAssigneeOption, currentUserAssigneeOption,
formatAssigneeUserLabel, formatAssigneeUserLabel,
parseAssigneeValue, parseAssigneeValue,
suggestedCommentAssigneeValue,
} from "./assignees"; } from "./assignees";
describe("assignee selection helpers", () => { describe("assignee selection helpers", () => {
@@ -50,4 +51,27 @@ describe("assignee selection helpers", () => {
expect(formatAssigneeUserLabel("local-board", "someone-else")).toBe("Board"); expect(formatAssigneeUserLabel("local-board", "someone-else")).toBe("Board");
expect(formatAssigneeUserLabel("user-abcdef", "someone-else")).toBe("user-"); expect(formatAssigneeUserLabel("user-abcdef", "someone-else")).toBe("user-");
}); });
it("suggests the last non-me commenter without changing the actual assignee encoding", () => {
expect(
suggestedCommentAssigneeValue(
{ assigneeUserId: "board-user" },
[
{ authorUserId: "board-user" },
{ authorAgentId: "agent-123" },
],
"board-user",
),
).toBe("agent:agent-123");
});
it("falls back to the actual assignee when there is no better commenter hint", () => {
expect(
suggestedCommentAssigneeValue(
{ assigneeUserId: "board-user" },
[{ authorUserId: "board-user" }],
"board-user",
),
).toBe("user:board-user");
});
}); });

View File

@@ -9,12 +9,40 @@ export interface AssigneeOption {
searchText?: string; searchText?: string;
} }
interface CommentAssigneeSuggestionInput {
assigneeAgentId?: string | null;
assigneeUserId?: string | null;
}
interface CommentAssigneeSuggestionComment {
authorAgentId?: string | null;
authorUserId?: string | null;
}
export function assigneeValueFromSelection(selection: Partial<AssigneeSelection>): string { export function assigneeValueFromSelection(selection: Partial<AssigneeSelection>): string {
if (selection.assigneeAgentId) return `agent:${selection.assigneeAgentId}`; if (selection.assigneeAgentId) return `agent:${selection.assigneeAgentId}`;
if (selection.assigneeUserId) return `user:${selection.assigneeUserId}`; if (selection.assigneeUserId) return `user:${selection.assigneeUserId}`;
return ""; return "";
} }
export function suggestedCommentAssigneeValue(
issue: CommentAssigneeSuggestionInput,
comments: CommentAssigneeSuggestionComment[] | null | undefined,
currentUserId: string | null | undefined,
): string {
if (comments && comments.length > 0 && currentUserId) {
for (let i = comments.length - 1; i >= 0; i--) {
const comment = comments[i];
if (comment.authorAgentId) return assigneeValueFromSelection({ assigneeAgentId: comment.authorAgentId });
if (comment.authorUserId && comment.authorUserId !== currentUserId) {
return assigneeValueFromSelection({ assigneeUserId: comment.authorUserId });
}
}
}
return assigneeValueFromSelection(issue);
}
export function parseAssigneeValue(value: string): AssigneeSelection { export function parseAssigneeValue(value: string): AssigneeSelection {
if (!value) { if (!value) {
return { assigneeAgentId: null, assigneeUserId: null }; return { assigneeAgentId: null, assigneeUserId: null };

View File

@@ -11,6 +11,7 @@ import { useCompany } from "../context/CompanyContext";
import { usePanel } from "../context/PanelContext"; import { usePanel } from "../context/PanelContext";
import { useToast } from "../context/ToastContext"; import { useToast } from "../context/ToastContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb"; import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
import { useProjectOrder } from "../hooks/useProjectOrder"; import { useProjectOrder } from "../hooks/useProjectOrder";
@@ -374,21 +375,15 @@ export function IssueDetail() {
return options; return options;
}, [agents, currentUserId]); }, [agents, currentUserId]);
const currentAssigneeValue = useMemo(() => { const actualAssigneeValue = useMemo(
// Default to the last commenter who is not "me" so the user doesn't () => assigneeValueFromSelection(issue ?? {}),
// accidentally reassign to themselves when commenting on their own issue. [issue],
if (comments && comments.length > 0 && currentUserId) { );
for (let i = comments.length - 1; i >= 0; i--) {
const c = comments[i]; const suggestedAssigneeValue = useMemo(
if (c.authorAgentId) return `agent:${c.authorAgentId}`; () => suggestedCommentAssigneeValue(issue ?? {}, comments, currentUserId),
if (c.authorUserId && c.authorUserId !== currentUserId) [issue, comments, currentUserId],
return `user:${c.authorUserId}`; );
}
}
if (issue?.assigneeAgentId) return `agent:${issue.assigneeAgentId}`;
if (issue?.assigneeUserId) return `user:${issue.assigneeUserId}`;
return "";
}, [issue?.assigneeAgentId, issue?.assigneeUserId, comments, currentUserId]);
const commentsWithRunMeta = useMemo(() => { const commentsWithRunMeta = useMemo(() => {
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null }>(); const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null }>();
@@ -1011,7 +1006,8 @@ export function IssueDetail() {
draftKey={`paperclip:issue-comment-draft:${issue.id}`} draftKey={`paperclip:issue-comment-draft:${issue.id}`}
enableReassign enableReassign
reassignOptions={commentReassignOptions} reassignOptions={commentReassignOptions}
currentAssigneeValue={currentAssigneeValue} currentAssigneeValue={actualAssigneeValue}
suggestedAssigneeValue={suggestedAssigneeValue}
mentions={mentionOptions} mentions={mentionOptions}
onAdd={async (body, reopen, reassignment) => { onAdd={async (body, reopen, reassignment) => {
if (reassignment) { if (reassignment) {