- {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")