Compare commits

...

4 Commits

Author SHA1 Message Date
J
064b85b19b 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 <github@multica.ai>
2026-06-08 14:49:46 +08:00
J
5e489b03e0 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 <github@multica.ai>
2026-06-08 14:39:03 +08:00
J
6e37b298b3 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 <github@multica.ai>
2026-06-08 14:06:06 +08:00
J
8ff2e28422 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 <github@multica.ai>
2026-06-08 14:05:57 +08:00
12 changed files with 291 additions and 32 deletions

View File

@@ -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<ContentEditorRef, ContentEditorProps>(
mentionMode = "default",
mentionContextItems,
enableSlashCommands = false,
slashCommandMode = "skill",
attachments,
},
ref,
@@ -228,6 +235,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
mentionMode,
getMentionContextItems: () => mentionContextItemsRef.current,
enableSlashCommands,
slashCommandMode,
}),
onUpdate: ({ editor: ed }) => {
if (!onUpdateRef.current) return;

View File

@@ -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 }),

View File

@@ -42,6 +42,8 @@ import {
type SlashCommandListRef,
createSlashCommandSuggestion,
type SlashCommandItem,
buildBuiltinCommandItems,
BUILTIN_COMMANDS,
} from "./slash-command-suggestion";
function agent(overrides: Partial<Agent>): 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(
<I18nWrapper>
<SlashCommandList items={[]} query="6" command={vi.fn()} hideOnEmpty />
</I18nWrapper>,
);
// 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(
<I18nWrapper>
<SlashCommandList
items={buildBuiltinCommandItems("")}
query=""
command={vi.fn()}
hideOnEmpty
/>
</I18nWrapper>,
);
expect(getByText("/note")).toBeInTheDocument();
expect(
getByText("Add a note — won't trigger any agents"),
).toBeInTheDocument();
});
});

View File

@@ -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 (
<div className="rounded-md border bg-popover p-2 text-xs text-muted-foreground shadow-md">
{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 (
<div className="rounded-md border bg-popover py-1 shadow-md w-72 max-h-[300px] overflow-y-auto">
{items.map((item, index) => (
<button
key={item.id}
ref={(el) => {
itemRefs.current[index] = el;
}}
className={`flex w-full flex-col gap-0.5 px-3 py-1.5 text-left text-xs transition-colors ${
selectedIndex === index ? "bg-accent" : "hover:bg-accent/50"
}`}
onClick={() => selectItem(index)}
>
<span className="font-medium">/{item.label}</span>
{item.description && (
<span className="truncate text-muted-foreground">
{item.description}
</span>
)}
</button>
))}
{items.map((item, index) => {
const description = describe(item);
return (
<button
key={item.id}
ref={(el) => {
itemRefs.current[index] = el;
}}
className={`flex w-full flex-col gap-0.5 px-3 py-1.5 text-left text-xs transition-colors ${
selectedIndex === index ? "bg-accent" : "hover:bg-accent/50"
}`}
onClick={() => selectItem(index)}
>
<span className="font-medium">/{item.label}</span>
{description && (
<span className="truncate text-muted-foreground">
{description}
</span>
)}
</button>
);
})}
</div>
);
});
@@ -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<SlashCommandItem>,
"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<SlashCommandItem, SlashCommandItem, SlashCommandListRef, SlashCommandListProps>({
pluginKey,
component: SlashCommandList,
getProps: (props) => ({
items: props.items,
query: props.query,
command: props.command,
hideOnEmpty: true,
}),
onKeyDown: (ref, props) => ref?.onKeyDown(props) ?? false,
}),
};
}

View File

@@ -106,6 +106,8 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
debounceMs={100}
currentIssueId={issueId}
attachments={pendingAttachments}
enableSlashCommands
slashCommandMode="command"
/>
</div>
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">

View File

@@ -139,6 +139,8 @@ function ReplyInput({
debounceMs={100}
currentIssueId={issueId}
attachments={pendingAttachments}
enableSlashCommands
slashCommandMode="command"
/>
</div>
<div className="absolute bottom-0 right-0 flex items-center gap-1">

View File

@@ -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",

View File

@@ -80,6 +80,9 @@
},
"slash_command": {
"no_skills_configured": "設定済みスキルなし",
"no_results": "一致するスキルなし"
"no_results": "一致するスキルなし",
"commands": {
"note": "メモを追加 — エージェントをトリガーしません"
}
}
}

View File

@@ -80,6 +80,9 @@
},
"slash_command": {
"no_skills_configured": "구성된 스킬 없음",
"no_results": "일치하는 스킬 없음"
"no_results": "일치하는 스킬 없음",
"commands": {
"note": "메모 추가 — 에이전트를 트리거하지 않음"
}
}
}

View File

@@ -64,7 +64,10 @@
},
"slash_command": {
"no_skills_configured": "暂无配置的技能",
"no_results": "没有匹配的技能"
"no_results": "没有匹配的技能",
"commands": {
"note": "添加备注 — 不触发任何 Agent"
}
},
"code_block": {
"copy_code": "复制代码",

View File

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

View File

@@ -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)
}