From dfc159e1aade77f2e27e8abe1651520917d54ff4 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:50:52 +0800 Subject: [PATCH] feat: skip agent triggering on /note-prefixed comments (MUL-3115, #3649) (#3885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(comments): skip agent triggering on /note-prefixed comments A comment whose first token is the reserved /note prefix (case-insensitive) is stored like any other comment but never wakes an agent. The guard sits at the top of triggerTasksForComment, the single chokepoint, so it covers all three trigger paths — assignee, squad leader, and @mentioned agents. Gating only shouldEnqueueOnComment (as originally proposed) would still let "/note @agent ..." through the mention path. Lets members leave human-only tips/notes on agent-assigned issues without burning an agent run. MUL-3115, closes #3649. Co-authored-by: multica-agent * feat(editor): add /note built-in slash command to comment composer Enable the `/` menu in the issue comment and reply composers in a new "command" mode that lists fixed built-in commands instead of the chat skill picker. Currently one command, /note, which marks a comment as a human-only note that won't trigger the assigned agent. Selecting it inserts the plain-text "/note " prefix (not a rich node), so a menu pick and a hand-typed command are byte-identical and the backend detects either with a simple prefix match. The command menu renders nothing on a non-matching `/` (hideOnEmpty) so typing a date like 6/8 isn't noisy. The chat skill picker is unchanged. MUL-3115. Co-authored-by: multica-agent * refactor(editor): match /note by label prefix and localize its description Address PR review feedback: - buildBuiltinCommandItems now matches the command label as a prefix only, dropping the description substring match copied from the skill picker. With one command this keeps the menu predictable (/no surfaces note; /deploy or a description word like /agent shows nothing) and avoids Enter selecting note unexpectedly. - The command description is now a localized UI string: added slash_command.commands.note to all four editor locales (en/ja/ko/zh-Hans) and the menu renders it via the typed translator. The /label itself stays literal since it's the typed token the backend matches. MUL-3115. Co-authored-by: multica-agent * fix(editor): shorten /note command description to avoid truncation The slash menu item is single-line (truncate, w-72), so the longer copy was cut off. Shorten to "won't trigger any agents" across all four locales — also more accurate, since /note skips assignee, squad leader, and @mentioned agents, not just the assigned one. MUL-3115. Co-authored-by: multica-agent --------- Co-authored-by: J Co-authored-by: multica-agent --- packages/views/editor/content-editor.tsx | 10 +- packages/views/editor/extensions/index.ts | 21 ++- .../slash-command-suggestion.test.tsx | 55 ++++++++ .../extensions/slash-command-suggestion.tsx | 131 +++++++++++++++--- .../views/issues/components/comment-input.tsx | 2 + .../views/issues/components/reply-input.tsx | 2 + packages/views/locales/en/editor.json | 5 +- packages/views/locales/ja/editor.json | 5 +- packages/views/locales/ko/editor.json | 5 +- packages/views/locales/zh-Hans/editor.json | 5 +- server/internal/handler/comment.go | 30 ++++ server/internal/handler/trigger_test.go | 52 +++++++ 12 files changed, 291 insertions(+), 32 deletions(-) diff --git a/packages/views/editor/content-editor.tsx b/packages/views/editor/content-editor.tsx index 744d149f0..22b5185de 100644 --- a/packages/views/editor/content-editor.tsx +++ b/packages/views/editor/content-editor.tsx @@ -105,8 +105,14 @@ interface ContentEditorProps { /** Chat can surface current/recent issue/project suggestions. Other editors use default mention behavior. */ mentionMode?: "default" | "context"; mentionContextItems?: MentionItem[]; - /** Enable the chat-only `/` skill picker. Defaults false. */ + /** Enable the `/` command picker. Defaults false. */ enableSlashCommands?: boolean; + /** + * Which `/` menu to show when enableSlashCommands is true: "skill" (default) + * lists the active agent's skills (chat); "command" shows the fixed built-in + * command menu (issue comments), e.g. /note. + */ + slashCommandMode?: "skill" | "command"; /** * Attachments referenced by this content. The download buttons on file * cards and images inside the editor look up an attachment by `url` and @@ -153,6 +159,7 @@ const ContentEditor = forwardRef( mentionMode = "default", mentionContextItems, enableSlashCommands = false, + slashCommandMode = "skill", attachments, }, ref, @@ -228,6 +235,7 @@ const ContentEditor = forwardRef( mentionMode, getMentionContextItems: () => mentionContextItemsRef.current, enableSlashCommands, + slashCommandMode, }), onUpdate: ({ editor: ed }) => { if (!onUpdateRef.current) return; diff --git a/packages/views/editor/extensions/index.ts b/packages/views/editor/extensions/index.ts index 450685628..64531ecaa 100644 --- a/packages/views/editor/extensions/index.ts +++ b/packages/views/editor/extensions/index.ts @@ -40,7 +40,7 @@ import { escapeMarkdownLabel } from "../utils/escape-markdown-label"; import { BaseMentionExtension } from "./mention-extension"; import { createMentionSuggestion, type MentionItem } from "./mention-suggestion"; import { SlashCommandExtension } from "./slash-command-extension"; -import { createSlashCommandSuggestion } from "./slash-command-suggestion"; +import { createSlashCommandSuggestion, createBuiltinCommandSuggestion } from "./slash-command-suggestion"; import { CodeBlockView } from "./code-block-view"; import { PatchedListItem, PatchedTaskItem } from "./list-item"; import { createMarkdownPasteExtension } from "./markdown-paste"; @@ -136,8 +136,14 @@ export interface EditorExtensionsOptions { /** Override @ behavior for chat context suggestions. */ mentionMode?: "default" | "context"; getMentionContextItems?: () => MentionItem[]; - /** When true, attach the `/` skill picker. Default false. */ + /** When true, attach the `/` picker. Default false. */ enableSlashCommands?: boolean; + /** + * Which `/` menu to attach when enableSlashCommands is true: + * - "skill" (default) — the chat picker listing the active agent's skills. + * - "command" — the fixed built-in command menu (issue comments), e.g. /note. + */ + slashCommandMode?: "skill" | "command"; } export function createEditorExtensions( @@ -197,10 +203,13 @@ export function createEditorExtensions( }), SlashCommandExtension.configure({ HTMLAttributes: { class: "slash-command" }, - suggestion: - options.enableSlashCommands && options.queryClient - ? createSlashCommandSuggestion(options.queryClient) - : { char: "/", allow: () => false }, + suggestion: !options.enableSlashCommands + ? { char: "/", allow: () => false } + : options.slashCommandMode === "command" + ? createBuiltinCommandSuggestion() + : options.queryClient + ? createSlashCommandSuggestion(options.queryClient) + : { char: "/", allow: () => false }, }), Typography, Placeholder.configure({ placeholder: placeholderText }), diff --git a/packages/views/editor/extensions/slash-command-suggestion.test.tsx b/packages/views/editor/extensions/slash-command-suggestion.test.tsx index bb66b2aa0..549078331 100644 --- a/packages/views/editor/extensions/slash-command-suggestion.test.tsx +++ b/packages/views/editor/extensions/slash-command-suggestion.test.tsx @@ -42,6 +42,8 @@ import { type SlashCommandListRef, createSlashCommandSuggestion, type SlashCommandItem, + buildBuiltinCommandItems, + BUILTIN_COMMANDS, } from "./slash-command-suggestion"; function agent(overrides: Partial): Agent { @@ -326,4 +328,57 @@ describe("SlashCommandList empty states", () => { expect(getByText("No matching skills")).toBeInTheDocument(); }); + + it("renders nothing on empty items when hideOnEmpty is set (command menu)", () => { + const { container } = render( + + + , + ); + + // No popup box on a non-matching `/` (e.g. typing a date like 6/8). + expect(container).toBeEmptyDOMElement(); + }); +}); + +describe("buildBuiltinCommandItems", () => { + it("returns the full built-in command set for an empty query", () => { + expect(buildBuiltinCommandItems("")).toEqual(BUILTIN_COMMANDS); + }); + + it("includes /note while the query is a prefix of the label", () => { + expect(buildBuiltinCommandItems("no").map((c) => c.id)).toEqual(["note"]); + expect(buildBuiltinCommandItems("NOTE").map((c) => c.id)).toEqual(["note"]); + }); + + it("matches the label as a prefix only — not the description", () => { + // "agent" appears in the description but is not a label prefix. + expect(buildBuiltinCommandItems("agent")).toEqual([]); + // A non-prefix substring of the label does not match either. + expect(buildBuiltinCommandItems("ote")).toEqual([]); + }); + + it("returns nothing for a query that matches no command", () => { + expect(buildBuiltinCommandItems("deploy")).toEqual([]); + }); +}); + +describe("SlashCommandList built-in command rendering", () => { + it("renders the localized description for a built-in command", () => { + const { getByText } = render( + + + , + ); + + expect(getByText("/note")).toBeInTheDocument(); + expect( + getByText("Add a note — won't trigger any agents"), + ).toBeInTheDocument(); + }); }); diff --git a/packages/views/editor/extensions/slash-command-suggestion.tsx b/packages/views/editor/extensions/slash-command-suggestion.tsx index 5e2b1e15b..8eda201b4 100644 --- a/packages/views/editor/extensions/slash-command-suggestion.tsx +++ b/packages/views/editor/extensions/slash-command-suggestion.tsx @@ -23,16 +23,34 @@ import { createSuggestionPopupRender } from "./suggestion-popup"; const MAX_ITEMS = 20; +/** Known built-in command ids — the keys under editor `slash_command.commands`. */ +export type BuiltinCommandKey = "note"; + export interface SlashCommandItem { id: string; label: string; - description: string; + /** Raw description (skill picker). Built-in commands use descriptionKey. */ + description?: string; + /** + * For built-in commands: the i18n key under editor `slash_command.commands`. + * When set, the menu renders the translated copy instead of `description`, + * so the visible string stays localized (the typed `/label` does not). + */ + descriptionKey?: BuiltinCommandKey; } interface SlashCommandListProps { items: SlashCommandItem[]; query: string; command: (item: SlashCommandItem) => void; + /** + * When true, render nothing instead of an empty-state box when there are no + * matching items. Used by the built-in command menu in issue comments, where + * `/` is common in prose (paths, dates) and a popup on every slash would be + * noise. The chat skill picker leaves this false so it can still explain + * "no skills configured". + */ + hideOnEmpty?: boolean; } export interface SlashCommandListRef { @@ -42,7 +60,7 @@ export interface SlashCommandListRef { export const SlashCommandList = forwardRef< SlashCommandListRef, SlashCommandListProps ->(function SlashCommandList({ items, query, command }, ref) { +>(function SlashCommandList({ items, query, command, hideOnEmpty = false }, ref) { const { t } = useT("editor"); const [selectedIndex, setSelectedIndex] = useState(0); const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); @@ -87,6 +105,7 @@ export const SlashCommandList = forwardRef< })); if (items.length === 0) { + if (hideOnEmpty) return null; return (
{t(($) => @@ -98,27 +117,37 @@ export const SlashCommandList = forwardRef< ); } + // Built-in commands carry an i18n key so the visible description stays + // localized; skills carry a raw description string from their config. + const describe = (item: SlashCommandItem): string | undefined => + item.descriptionKey === "note" + ? t(($) => $.slash_command.commands.note) + : item.description; + return (
- {items.map((item, index) => ( - - ))} + {items.map((item, index) => { + const description = describe(item); + return ( + + ); + })}
); }); @@ -205,3 +234,63 @@ export function createSlashCommandSuggestion(qc: QueryClient): Omit< }), }; } + +// --------------------------------------------------------------------------- +// Built-in command menu (issue comments) +// --------------------------------------------------------------------------- + +/** + * Built-in slash commands offered in the issue comment composer. Unlike the + * chat `/` picker (which lists the active agent's skills), these are a fixed, + * hand-curated set. Currently only `/note`, which marks a comment as a + * human-only note that won't trigger the assigned agent — mirrors the backend + * `noteCommentPrefix` in server/internal/handler/comment.go. + */ +export const BUILTIN_COMMANDS: SlashCommandItem[] = [ + { id: "note", label: "note", descriptionKey: "note" }, +]; + +// Match on the command label as a prefix only — the description is for display, +// not search. With a single command this keeps the menu predictable (typing +// `/no` surfaces `note`; an unrelated `/deploy` shows nothing). +export function buildBuiltinCommandItems(query: string): SlashCommandItem[] { + const q = query.toLowerCase(); + return BUILTIN_COMMANDS.filter((c) => c.label.toLowerCase().startsWith(q)); +} + +export function createBuiltinCommandSuggestion(): Omit< + SuggestionOptions, + "editor" +> { + const pluginKey = new PluginKey("builtinCommandSuggestion"); + + return { + char: "/", + pluginKey, + items: ({ query }) => buildBuiltinCommandItems(query), + command: ({ editor, range, props }) => { + // Insert the plain-text prefix (e.g. "/note ") rather than a rich node, + // so a menu selection and a hand-typed command are byte-identical and the + // backend can detect the marker with a simple prefix match. The trailing + // space terminates the suggestion match so the menu does not re-open. + editor + .chain() + .focus() + .insertContentAt(range, [{ type: "text", text: `/${props.label} ` }]) + .run(); + + window.getSelection()?.collapseToEnd(); + }, + render: createSuggestionPopupRender({ + pluginKey, + component: SlashCommandList, + getProps: (props) => ({ + items: props.items, + query: props.query, + command: props.command, + hideOnEmpty: true, + }), + onKeyDown: (ref, props) => ref?.onKeyDown(props) ?? false, + }), + }; +} diff --git a/packages/views/issues/components/comment-input.tsx b/packages/views/issues/components/comment-input.tsx index b93641042..fd54d4883 100644 --- a/packages/views/issues/components/comment-input.tsx +++ b/packages/views/issues/components/comment-input.tsx @@ -106,6 +106,8 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) { debounceMs={100} currentIssueId={issueId} attachments={pendingAttachments} + enableSlashCommands + slashCommandMode="command" />
diff --git a/packages/views/issues/components/reply-input.tsx b/packages/views/issues/components/reply-input.tsx index 6d0eacecf..72feb68fa 100644 --- a/packages/views/issues/components/reply-input.tsx +++ b/packages/views/issues/components/reply-input.tsx @@ -139,6 +139,8 @@ function ReplyInput({ debounceMs={100} currentIssueId={issueId} attachments={pendingAttachments} + enableSlashCommands + slashCommandMode="command" />
diff --git a/packages/views/locales/en/editor.json b/packages/views/locales/en/editor.json index 35c06c015..8d84d1654 100644 --- a/packages/views/locales/en/editor.json +++ b/packages/views/locales/en/editor.json @@ -64,7 +64,10 @@ }, "slash_command": { "no_skills_configured": "No skills configured", - "no_results": "No matching skills" + "no_results": "No matching skills", + "commands": { + "note": "Add a note — won't trigger any agents" + } }, "code_block": { "copy_code": "Copy code", diff --git a/packages/views/locales/ja/editor.json b/packages/views/locales/ja/editor.json index cc5cd3ca9..cc619f163 100644 --- a/packages/views/locales/ja/editor.json +++ b/packages/views/locales/ja/editor.json @@ -80,6 +80,9 @@ }, "slash_command": { "no_skills_configured": "設定済みスキルなし", - "no_results": "一致するスキルなし" + "no_results": "一致するスキルなし", + "commands": { + "note": "メモを追加 — エージェントをトリガーしません" + } } } diff --git a/packages/views/locales/ko/editor.json b/packages/views/locales/ko/editor.json index 115d5fb2d..06155c114 100644 --- a/packages/views/locales/ko/editor.json +++ b/packages/views/locales/ko/editor.json @@ -80,6 +80,9 @@ }, "slash_command": { "no_skills_configured": "구성된 스킬 없음", - "no_results": "일치하는 스킬 없음" + "no_results": "일치하는 스킬 없음", + "commands": { + "note": "메모 추가 — 에이전트를 트리거하지 않음" + } } } diff --git a/packages/views/locales/zh-Hans/editor.json b/packages/views/locales/zh-Hans/editor.json index f9714c20a..368556e9c 100644 --- a/packages/views/locales/zh-Hans/editor.json +++ b/packages/views/locales/zh-Hans/editor.json @@ -64,7 +64,10 @@ }, "slash_command": { "no_skills_configured": "暂无配置的技能", - "no_results": "没有匹配的技能" + "no_results": "没有匹配的技能", + "commands": { + "note": "添加备注 — 不触发任何 Agent" + } }, "code_block": { "copy_code": "复制代码", diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index 6694869e5..fd220e37c 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -6,7 +6,9 @@ import ( "log/slog" "net/http" "strconv" + "strings" "time" + "unicode" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" @@ -928,7 +930,35 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, resp) } +// noteCommentPrefix marks a comment as a human-only note. A comment whose first +// whitespace-delimited token is this prefix (case-insensitive) is stored like +// any other comment but never triggers an agent — see triggerTasksForComment. +const noteCommentPrefix = "/note" + +// isNoteComment reports whether content opts out of agent triggering via the +// reserved /note prefix. The prefix must be the comment's first token, so +// "/note check expiry", " /NOTE", and "/note" all match, while "/notes", +// "/ note", and "see foo/note" do not. +func isNoteComment(content string) bool { + trimmed := strings.TrimLeft(content, " \t\r\n") + firstToken := trimmed + if i := strings.IndexFunc(trimmed, unicode.IsSpace); i >= 0 { + firstToken = trimmed[:i] + } + return strings.EqualFold(firstToken, noteCommentPrefix) +} + func (h *Handler) triggerTasksForComment(ctx context.Context, issue db.Issue, comment db.Comment, parentComment *db.Comment, actorType, actorID string) { + // A comment opening with the reserved /note prefix is a human-only note: it + // is recorded like any other comment but must not wake ANY agent. This guard + // lives at the single chokepoint so it covers all three trigger paths below + // (assignee, squad leader, and @mentioned agents). Gating only + // shouldEnqueueOnComment would still let "/note @agent ..." reach an agent + // through the mention path. + if isNoteComment(comment.Content) { + return + } + if actorType == "member" && h.shouldEnqueueOnComment(ctx, issue, actorType, actorID) && !h.commentMentionsOthersButNotAssignee(comment.Content, issue) && !h.isReplyToMemberThread(ctx, parentComment, comment.Content, issue) { diff --git a/server/internal/handler/trigger_test.go b/server/internal/handler/trigger_test.go index e46e8da34..e5c5b2a53 100644 --- a/server/internal/handler/trigger_test.go +++ b/server/internal/handler/trigger_test.go @@ -315,3 +315,55 @@ func TestOnCommentTriggerDecision(t *testing.T) { }) } } + +// ------------------------------------------------------------------- +// isNoteComment — the /note opt-out prefix +// ------------------------------------------------------------------- + +func TestIsNoteComment(t *testing.T) { + tests := []struct { + name string + content string + want bool + }{ + {"plain comment triggers", "just a plain comment", false}, + {"note prefix skips", "/note check the API expiry", true}, + {"bare note skips", "/note", true}, + {"uppercase note skips (case-insensitive)", "/NOTE shout", true}, + {"mixed case note skips", "/Note mixed", true}, + {"leading whitespace tolerated", " /note leading space", true}, + {"note followed by newline skips", "/note\nmultiline body", true}, + {"plural notes does not match (word boundary)", "/notes are plural", false}, + {"noteworthy does not match", "/noteworthy idea", false}, + {"slash space note does not match", "/ note has a space", false}, + {"mid-sentence note does not match", "see foo/note here", false}, + {"note as second token does not match", "fyi /note", false}, + {"empty content does not match", "", false}, + {"whitespace-only content does not match", " ", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isNoteComment(tt.content); got != tt.want { + t.Errorf("isNoteComment(%q) = %v, want %v", tt.content, got, tt.want) + } + }) + } +} + +// TestTriggerTasksForComment_NoteShortCircuits proves a /note comment returns +// before any of the three enqueue paths run. shouldEnqueueOnComment, +// shouldEnqueueSquadLeaderOnComment, and enqueueMentionedAgentTasks all +// dereference h.Queries, so a nil-Queries Handler would panic if the /note +// guard were missing or moved below them. The comment also @mentions an agent +// to exercise the mention path specifically. +func TestTriggerTasksForComment_NoteShortCircuits(t *testing.T) { + h := &Handler{} // nil Queries / TaskService on purpose + issue := issueWithAgentAssignee() + comment := db.Comment{ + Content: fmt.Sprintf("/note cc [@Other](mention://agent/%s) just an fyi", otherAgentID), + } + + // Must not panic — the guard short-circuits before any DB access. + h.triggerTasksForComment(context.Background(), issue, comment, nil, "member", memberID) +}