From 1afa4931654dfc83adaa7af932023d65e4ce01b3 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 16 Jun 2026 08:20:15 +0800 Subject: [PATCH] fix(comments): align trigger preview context (#4147) Co-authored-by: multica-agent --- .../views/issues/components/comment-card.tsx | 6 ++ .../views/issues/components/comment-input.tsx | 5 ++ .../components/comment-trigger-chips.test.tsx | 16 ++++ .../components/comment-trigger-chips.tsx | 54 +++++++++++- .../views/issues/components/reply-input.tsx | 5 ++ .../hooks/use-comment-trigger-preview.test.ts | 83 +++++++++++++++++++ .../hooks/use-comment-trigger-preview.ts | 30 +++++-- packages/views/locales/en/issues.json | 6 ++ packages/views/locales/ja/issues.json | 6 ++ packages/views/locales/ko/issues.json | 6 ++ packages/views/locales/zh-Hans/issues.json | 6 ++ server/internal/handler/comment.go | 2 +- .../handler/comment_trigger_preview_test.go | 63 +++++++++++++- 13 files changed, 280 insertions(+), 8 deletions(-) diff --git a/packages/views/issues/components/comment-card.tsx b/packages/views/issues/components/comment-card.tsx index 9f0e5829e..ef425b707 100644 --- a/packages/views/issues/components/comment-card.tsx +++ b/packages/views/issues/components/comment-card.tsx @@ -283,6 +283,10 @@ function useEditAttachmentState( return result; }, [uploadWithToast, issueId]); + useEffect(() => { + setSuppressedAgentIds(new Set()); + }, [issueId, entry.id, entry.parent_id]); + const { isDragOver, dropZoneProps } = useFileDropZone({ onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)), enabled: editing, @@ -579,6 +583,7 @@ function CommentRow({ agents={edit.triggerPreview.agents} suppressedAgentIds={edit.suppressedAgentIds} onToggle={edit.toggleSuppressedAgent} + context="edit" /> { + setSuppressedAgentIds(new Set()); + }, [issueId]); + useEffect(() => { const visible = new Set(triggerPreview.agents.map((agent) => agent.id)); setSuppressedAgentIds((prev) => { @@ -151,6 +155,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) { agents={triggerPreview.agents} suppressedAgentIds={suppressedAgentIds} onToggle={toggleSuppressedAgent} + context="comment" />
diff --git a/packages/views/issues/components/comment-trigger-chips.test.tsx b/packages/views/issues/components/comment-trigger-chips.test.tsx index 26c7dfade..3589f2ca9 100644 --- a/packages/views/issues/components/comment-trigger-chips.test.tsx +++ b/packages/views/issues/components/comment-trigger-chips.test.tsx @@ -45,6 +45,7 @@ describe("CommentTriggerChips", () => { ); const chip = screen.getByRole("button"); + expect(chip).toHaveTextContent("Comment"); expect(chip).toHaveTextContent("Starts working when sent"); expect(chip).toHaveAttribute("aria-pressed", "false"); @@ -52,6 +53,19 @@ describe("CommentTriggerChips", () => { expect(onToggle).toHaveBeenCalledWith("agent-1"); }); + it("labels reply trigger chips by composer context", () => { + renderWithI18n( + , + ); + + expect(screen.getByRole("button")).toHaveTextContent("Reply"); + }); + it("dims a suppressed single agent into the skip state", () => { renderWithI18n( { agents={[walt, bob]} suppressedAgentIds={new Set()} onToggle={onToggle} + context="reply" />, ); fireEvent.click(screen.getByRole("button")); + expect(screen.getByText("This reply will trigger")).toBeInTheDocument(); const row = screen.getByRole("button", { name: /Bob/ }); expect(row).toHaveTextContent("Bob"); fireEvent.click(row); diff --git a/packages/views/issues/components/comment-trigger-chips.tsx b/packages/views/issues/components/comment-trigger-chips.tsx index 81cff0017..97e3630a6 100644 --- a/packages/views/issues/components/comment-trigger-chips.tsx +++ b/packages/views/issues/components/comment-trigger-chips.tsx @@ -29,9 +29,11 @@ interface CommentTriggerChipsProps { agents: CommentTriggerPreviewAgent[]; suppressedAgentIds: Set; onToggle: (agentId: string) => void; + context?: CommentTriggerContext; } type IssuesT = ReturnType>["t"]; +type CommentTriggerContext = "comment" | "reply" | "edit"; function sourceLabel(source: string, t: IssuesT): string { switch (source) { @@ -59,6 +61,28 @@ function sourceReason(agent: CommentTriggerPreviewAgent, t: IssuesT): string { } } +function triggerContextLabel(context: CommentTriggerContext, t: IssuesT): string { + switch (context) { + case "reply": + return t(($) => $.comment.trigger_context_reply); + case "edit": + return t(($) => $.comment.trigger_context_edit); + default: + return t(($) => $.comment.trigger_context_comment); + } +} + +function triggerPreviewTitle(context: CommentTriggerContext, t: IssuesT): string { + switch (context) { + case "reply": + return t(($) => $.comment.trigger_preview_title_reply); + case "edit": + return t(($) => $.comment.trigger_preview_title_edit); + default: + return t(($) => $.comment.trigger_preview_title_comment); + } +} + // Presence is display metadata only — the trigger list itself is always the // backend preview. Online-ish agents start right away; offline ones queue. function useTriggerPresenceLine(agentId: string, t: IssuesT): string | null { @@ -107,6 +131,7 @@ export function CommentTriggerChips({ agents, suppressedAgentIds, onToggle, + context = "comment", }: CommentTriggerChipsProps) { const { t } = useT("issues"); @@ -121,6 +146,7 @@ export function CommentTriggerChips({ agent={agent} suppressed={suppressedAgentIds.has(agent.id)} onToggle={onToggle} + context={context} t={t} /> ); @@ -131,20 +157,42 @@ export function CommentTriggerChips({ agents={agents} suppressedAgentIds={suppressedAgentIds} onToggle={onToggle} + context={context} t={t} /> ); } +function TriggerContextPrefix({ + context, + t, +}: { + context: CommentTriggerContext; + t: IssuesT; +}) { + return ( + <> + + {triggerContextLabel(context, t)} + + + + ); +} + function SingleTriggerChip({ agent, suppressed, onToggle, + context, t, }: { agent: CommentTriggerPreviewAgent; suppressed: boolean; onToggle: (agentId: string) => void; + context: CommentTriggerContext; t: IssuesT; }) { const state = suppressed @@ -172,6 +220,7 @@ function SingleTriggerChip({ suppressed && "opacity-60", )} > + {sentence} @@ -188,11 +237,13 @@ function MultiTriggerChip({ agents, suppressedAgentIds, onToggle, + context, t, }: { agents: CommentTriggerPreviewAgent[]; suppressedAgentIds: Set; onToggle: (agentId: string) => void; + context: CommentTriggerContext; t: IssuesT; }) { const [open, setOpen] = useState(false); @@ -225,6 +276,7 @@ function MultiTriggerChip({ /> } > + {heads.map((agent, i) => (
- {t(($) => $.comment.trigger_preview_title)} + {triggerPreviewTitle(context, t)}
{agents.map((agent) => { diff --git a/packages/views/issues/components/reply-input.tsx b/packages/views/issues/components/reply-input.tsx index 1b0202455..3c291dc00 100644 --- a/packages/views/issues/components/reply-input.tsx +++ b/packages/views/issues/components/reply-input.tsx @@ -94,6 +94,10 @@ function ReplyInput({ return result; }, [uploadWithToast, issueId]); + useEffect(() => { + setSuppressedAgentIds(new Set()); + }, [issueId, parentId]); + useEffect(() => { const visible = new Set(triggerPreview.agents.map((agent) => agent.id)); setSuppressedAgentIds((prev) => { @@ -185,6 +189,7 @@ function ReplyInput({ agents={triggerPreview.agents} suppressedAgentIds={suppressedAgentIds} onToggle={toggleSuppressedAgent} + context="reply" />
diff --git a/packages/views/issues/hooks/use-comment-trigger-preview.test.ts b/packages/views/issues/hooks/use-comment-trigger-preview.test.ts index 7ab499b62..04e619c2b 100644 --- a/packages/views/issues/hooks/use-comment-trigger-preview.test.ts +++ b/packages/views/issues/hooks/use-comment-trigger-preview.test.ts @@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { api } from "@multica/core/api"; +import type { CommentTriggerPreviewAgent } from "@multica/core/types"; import { commentTriggerPreviewSignature, isNoteCommentDraft, @@ -16,6 +17,18 @@ vi.mock("@multica/core/api", () => ({ })); const previewCommentTriggers = vi.mocked(api.previewCommentTriggers); +const waltAgent: CommentTriggerPreviewAgent = { + id: "00000000-0000-0000-0000-000000000001", + name: "Walt", + source: "issue_assignee", + reason: "", +}; +const kimAgent: CommentTriggerPreviewAgent = { + id: "00000000-0000-0000-0000-000000000002", + name: "Kim", + source: "mention_agent", + reason: "", +}; function createWrapper() { const queryClient = new QueryClient({ @@ -107,6 +120,76 @@ describe("useCommentTriggerPreview", () => { ); }); + it("does not show previous agents while parent context changes", async () => { + previewCommentTriggers + .mockResolvedValueOnce({ agents: [waltAgent] }) + .mockReturnValueOnce(new Promise(() => {})); + + const { result, rerender } = renderHook( + ({ parentId }) => + useCommentTriggerPreview({ + issueId: "issue-1", + parentId, + content: "plain reply", + }), + { + wrapper: createWrapper(), + initialProps: { parentId: "walt-thread" }, + }, + ); + + await advancePreviewDebounce(); + await vi.waitFor(() => { + expect(result.current.agents).toEqual([waltAgent]); + }); + + rerender({ parentId: "kim-thread" }); + await act(async () => {}); + + expect(result.current.agents).toEqual([]); + expect(previewCommentTriggers).toHaveBeenLastCalledWith( + "issue-1", + "plain reply", + "kim-thread", + undefined, + ); + }); + + it("keeps previous agents while the same context refetches", async () => { + previewCommentTriggers + .mockResolvedValueOnce({ agents: [waltAgent] }) + .mockReturnValueOnce(new Promise(() => {})); + + const { result, rerender } = renderHook( + ({ content }) => + useCommentTriggerPreview({ + issueId: "issue-1", + parentId: "same-thread", + content, + }), + { + wrapper: createWrapper(), + initialProps: { content: `[@Walt](mention://agent/${waltAgent.id})` }, + }, + ); + + await advancePreviewDebounce(); + await vi.waitFor(() => { + expect(result.current.agents).toEqual([waltAgent]); + }); + + rerender({ content: `[@Kim](mention://agent/${kimAgent.id})` }); + await advancePreviewDebounce(); + + expect(result.current.agents).toEqual([waltAgent]); + expect(previewCommentTriggers).toHaveBeenLastCalledWith( + "issue-1", + `[@Kim](mention://agent/${kimAgent.id})`, + "same-thread", + undefined, + ); + }); + it("revalidates when the debounced signature repeats — the answer is queue-state dependent", async () => { const agentA = "00000000-0000-0000-0000-000000000001"; const content = `[@A](mention://agent/${agentA})`; diff --git a/packages/views/issues/hooks/use-comment-trigger-preview.ts b/packages/views/issues/hooks/use-comment-trigger-preview.ts index 2b8385507..6764ea2ef 100644 --- a/packages/views/issues/hooks/use-comment-trigger-preview.ts +++ b/packages/views/issues/hooks/use-comment-trigger-preview.ts @@ -36,6 +36,21 @@ export function commentTriggerPreviewSignature(content: string): string { return `nonempty|${tokens.join(",")}`; } +function queryKeyMatchesPreviewContext( + queryKey: readonly unknown[] | undefined, + issueId: string, + parentId: string, + editingCommentId: string, +) { + if (!queryKey) return false; + const prefix = issueKeys.commentTriggerPreview(issueId); + return ( + prefix.every((part, index) => queryKey[index] === part) && + queryKey[prefix.length] === parentId && + queryKey[prefix.length + 1] === editingCommentId + ); +} + function useDebouncedSignature(signature: string) { const [debouncedSignature, setDebouncedSignature] = useState("empty"); @@ -69,13 +84,15 @@ export function useCommentTriggerPreview({ const signature = useMemo(() => commentTriggerPreviewSignature(content), [content]); const debouncedSignature = useDebouncedSignature(signature); const contentRef = useRef(content); + const parentKey = parentId ?? ""; + const editingKey = editingCommentId ?? ""; useEffect(() => { contentRef.current = content; }, [content]); const previewQuery = useQuery({ - queryKey: [...issueKeys.commentTriggerPreview(issueId), parentId ?? "", editingCommentId ?? "", debouncedSignature], + queryKey: [...issueKeys.commentTriggerPreview(issueId), parentKey, editingKey, debouncedSignature], queryFn: () => api.previewCommentTriggers(issueId, contentRef.current, parentId, editingCommentId), enabled: signature !== "empty" && debouncedSignature !== "empty", retry: false, @@ -84,10 +101,13 @@ export function useCommentTriggerPreview({ // reappears — Infinity here once pinned a stale "nobody triggers" // snapshot taken while the agent was still queued. staleTime: 0, - // Keep the previous agent list while a new signature is fetching: - // without it the in-flight gap renders as "no agents", flickering the - // chips and wiping the composer's suppressed-id set. - placeholderData: keepPreviousData, + // Keep the previous agent list only while the same composer context is + // re-fetching. Crossing issue/parent/edit context must not display stale + // chips from another composer. + placeholderData: (previousData, previousQuery) => + queryKeyMatchesPreviewContext(previousQuery?.queryKey, issueId, parentKey, editingKey) + ? keepPreviousData(previousData) + : undefined, }); // Loading and errors intentionally surface as "no agents": the preview is diff --git a/packages/views/locales/en/issues.json b/packages/views/locales/en/issues.json index e6beca154..21084fce1 100644 --- a/packages/views/locales/en/issues.json +++ b/packages/views/locales/en/issues.json @@ -279,6 +279,9 @@ "trigger_source_mention_agent": "@mention", "trigger_source_mention_squad_leader": "squad", "trigger_source_unknown": "trigger", + "trigger_context_comment": "Comment", + "trigger_context_reply": "Reply", + "trigger_context_edit": "Edit", "trigger_skipped_label": "Skipped", "trigger_wont_trigger": "Won't be triggered", "trigger_none_will_trigger": "No agents will be triggered", @@ -295,6 +298,9 @@ "trigger_click_to_manage": "Click to manage who gets triggered this time.", "trigger_click_to_restore": "Won't be triggered this time. Click to restore.", "trigger_preview_title": "This comment will trigger", + "trigger_preview_title_comment": "This comment will trigger", + "trigger_preview_title_reply": "This reply will trigger", + "trigger_preview_title_edit": "This edit will trigger", "trigger_chip_aria": "{{name}} trigger: {{state}}", "resolve": { "resolve_thread_action": "Resolve thread", diff --git a/packages/views/locales/ja/issues.json b/packages/views/locales/ja/issues.json index b93c50a18..86dad4d89 100644 --- a/packages/views/locales/ja/issues.json +++ b/packages/views/locales/ja/issues.json @@ -271,6 +271,9 @@ "trigger_source_mention_agent": "@メンション", "trigger_source_mention_squad_leader": "Squad", "trigger_source_unknown": "トリガー", + "trigger_context_comment": "コメント", + "trigger_context_reply": "返信", + "trigger_context_edit": "編集", "trigger_skipped_label": "スキップ", "trigger_wont_trigger": "トリガーされません", "trigger_none_will_trigger": "いずれもトリガーされません", @@ -286,6 +289,9 @@ "trigger_click_to_manage": "クリックして今回のトリガー対象を管理します。", "trigger_click_to_restore": "今回はトリガーされません。クリックで元に戻せます。", "trigger_preview_title": "このコメントでトリガーされる対象", + "trigger_preview_title_comment": "このコメントでトリガーされる対象", + "trigger_preview_title_reply": "この返信でトリガーされる対象", + "trigger_preview_title_edit": "この編集でトリガーされる対象", "trigger_chip_aria": "{{name}} のトリガー: {{state}}", "resolve": { "resolve_thread_action": "スレッドを解決", diff --git a/packages/views/locales/ko/issues.json b/packages/views/locales/ko/issues.json index 4f057e0a2..b0f981175 100644 --- a/packages/views/locales/ko/issues.json +++ b/packages/views/locales/ko/issues.json @@ -279,6 +279,9 @@ "trigger_source_mention_agent": "@멘션", "trigger_source_mention_squad_leader": "Squad", "trigger_source_unknown": "트리거", + "trigger_context_comment": "댓글", + "trigger_context_reply": "답글", + "trigger_context_edit": "편집", "trigger_skipped_label": "건너뜀", "trigger_wont_trigger": "트리거되지 않습니다", "trigger_none_will_trigger": "모두 트리거되지 않습니다", @@ -294,6 +297,9 @@ "trigger_click_to_manage": "클릭하여 이번 트리거 대상을 관리합니다.", "trigger_click_to_restore": "이번에는 트리거되지 않습니다. 클릭하면 복원됩니다.", "trigger_preview_title": "이 댓글로 트리거되는 대상", + "trigger_preview_title_comment": "이 댓글로 트리거되는 대상", + "trigger_preview_title_reply": "이 답글로 트리거되는 대상", + "trigger_preview_title_edit": "이 편집으로 트리거되는 대상", "trigger_chip_aria": "{{name}} 트리거: {{state}}", "resolve": { "resolve_thread_action": "스레드 해결", diff --git a/packages/views/locales/zh-Hans/issues.json b/packages/views/locales/zh-Hans/issues.json index 6ad3f9f3f..c2b605f15 100644 --- a/packages/views/locales/zh-Hans/issues.json +++ b/packages/views/locales/zh-Hans/issues.json @@ -276,6 +276,9 @@ "trigger_source_mention_agent": "@mention", "trigger_source_mention_squad_leader": "squad", "trigger_source_unknown": "将触发", + "trigger_context_comment": "评论", + "trigger_context_reply": "回复", + "trigger_context_edit": "编辑", "trigger_skipped_label": "已跳过", "trigger_wont_trigger": "不会触发", "trigger_none_will_trigger": "全部不会触发", @@ -291,6 +294,9 @@ "trigger_click_to_manage": "点击管理本次触发对象。", "trigger_click_to_restore": "本次不触发,点击恢复。", "trigger_preview_title": "这条评论将触发", + "trigger_preview_title_comment": "这条评论将触发", + "trigger_preview_title_reply": "这条回复将触发", + "trigger_preview_title_edit": "本次编辑将触发", "trigger_chip_aria": "{{name}} 触发状态:{{state}}", "resolve": { "resolve_thread_action": "解决该讨论", diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index 9d822688c..2d688ad57 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -873,7 +873,7 @@ func (h *Handler) PreviewCommentTriggers(w http.ResponseWriter, r *http.Request) var parentID pgtype.UUID if req.ParentID != nil { - parentID, ok := parseUUIDOrBadRequest(w, *req.ParentID, "parent_id") + parentID, ok = parseUUIDOrBadRequest(w, *req.ParentID, "parent_id") if !ok { return } diff --git a/server/internal/handler/comment_trigger_preview_test.go b/server/internal/handler/comment_trigger_preview_test.go index 2711bd6f7..2b9ed34ef 100644 --- a/server/internal/handler/comment_trigger_preview_test.go +++ b/server/internal/handler/comment_trigger_preview_test.go @@ -49,7 +49,7 @@ func createCommentTriggerPreviewIssue(t *testing.T, title string, assigneeType, return issueID } -func previewCommentTriggersForTest(t *testing.T, issueID string, body map[string]any) CommentTriggerPreviewResponse { +func previewCommentTriggersForTest(t *testing.T, issueID string, body any) CommentTriggerPreviewResponse { t.Helper() w := httptest.NewRecorder() @@ -85,6 +85,20 @@ func postCommentForTriggerPreviewTest(t *testing.T, issueID string, body map[str return resp.ID } +func insertMemberRootCommentForTriggerPreviewTest(t *testing.T, issueID, content string) string { + t.Helper() + + var commentID string + if err := testPool.QueryRow(context.Background(), ` + INSERT INTO comment (workspace_id, issue_id, author_type, author_id, content) + VALUES ($1, $2, 'member', $3, $4) + RETURNING id + `, testWorkspaceID, issueID, testUserID, content).Scan(&commentID); err != nil { + t.Fatalf("insert member root comment: %v", err) + } + return commentID +} + func updateCommentForTriggerPreviewTest(t *testing.T, commentID string, body map[string]any) { t.Helper() @@ -143,6 +157,53 @@ func requirePreviewAgents(t *testing.T, preview CommentTriggerPreviewResponse, w } } +func TestPreviewCommentTriggers_MatchesCreateForInheritedParentMention(t *testing.T) { + if testHandler == nil || testPool == nil { + t.Skip("database not available") + } + + waltID := createHandlerTestAgent(t, "Preview Inherit Walt", nil) + kimID := createHandlerTestAgent(t, "Preview Inherit Kim", nil) + issueID := createCommentTriggerPreviewIssue(t, "comment trigger preview inherits parent mention", "agent", waltID) + + topLevelPreview := previewCommentTriggersForTest(t, issueID, CommentTriggerPreviewRequest{ + Content: "hello from the root composer", + }) + requirePreviewAgents(t, topLevelPreview, waltID) + + rootContent := fmt.Sprintf("[@Kim](mention://agent/%s) can you inspect this?", kimID) + rootID := insertMemberRootCommentForTriggerPreviewTest(t, issueID, rootContent) + if got := countQueuedCommentTriggerTasks(t, issueID, kimID); got != 0 { + t.Fatalf("fixture queued Kim tasks = %d, want 0", got) + } + if got := countQueuedCommentTriggerTasks(t, issueID, waltID); got != 0 { + t.Fatalf("fixture queued Walt tasks = %d, want 0", got) + } + + replyContent := "plain reply with no mention" + replyParentID := rootID + replyBody := map[string]any{ + "content": replyContent, + "parent_id": rootID, + } + replyPreview := previewCommentTriggersForTest(t, issueID, CommentTriggerPreviewRequest{ + Content: replyContent, + ParentID: &replyParentID, + }) + requirePreviewAgents(t, replyPreview, kimID) + if replyPreview.Agents[0].Source != string(commentTriggerSourceMentionAgent) { + t.Fatalf("reply preview source = %q, want %q", replyPreview.Agents[0].Source, commentTriggerSourceMentionAgent) + } + + postCommentForTriggerPreviewTest(t, issueID, replyBody) + if got := countQueuedCommentTriggerTasks(t, issueID, kimID); got != 1 { + t.Fatalf("plain reply queued Kim tasks = %d, want 1", got) + } + if got := countQueuedCommentTriggerTasks(t, issueID, waltID); got != 0 { + t.Fatalf("plain reply queued Walt tasks = %d, want 0", got) + } +} + func TestPreviewCommentTriggers_ReturnsMentionedAgentsAndSuppressFiltersCreate(t *testing.T) { if testHandler == nil || testPool == nil { t.Skip("database not available")