mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-29 18:39:17 +02:00
Compare commits
4 Commits
agent/lamb
...
agent/j/06
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
064b85b19b | ||
|
|
5e489b03e0 | ||
|
|
6e37b298b3 | ||
|
|
8ff2e28422 |
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -80,6 +80,9 @@
|
||||
},
|
||||
"slash_command": {
|
||||
"no_skills_configured": "設定済みスキルなし",
|
||||
"no_results": "一致するスキルなし"
|
||||
"no_results": "一致するスキルなし",
|
||||
"commands": {
|
||||
"note": "メモを追加 — エージェントをトリガーしません"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,9 @@
|
||||
},
|
||||
"slash_command": {
|
||||
"no_skills_configured": "구성된 스킬 없음",
|
||||
"no_results": "일치하는 스킬 없음"
|
||||
"no_results": "일치하는 스킬 없음",
|
||||
"commands": {
|
||||
"note": "메모 추가 — 에이전트를 트리거하지 않음"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,10 @@
|
||||
},
|
||||
"slash_command": {
|
||||
"no_skills_configured": "暂无配置的技能",
|
||||
"no_results": "没有匹配的技能"
|
||||
"no_results": "没有匹配的技能",
|
||||
"commands": {
|
||||
"note": "添加备注 — 不触发任何 Agent"
|
||||
}
|
||||
},
|
||||
"code_block": {
|
||||
"copy_code": "复制代码",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user