fix: suggest comment reassignment from recent commenter
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user