Merge pull request #304 from multica-ai/agent/lambda/95b033ec

feat(mentions): support @all to mention all workspace members
This commit is contained in:
Jiayuan Zhang 2026-04-01 21:03:28 +08:00 committed by GitHub
commit 6b9341f7ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 91 additions and 17 deletions

View file

@ -1,6 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { Users } from "lucide-react";
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { useWorkspaceStore } from "@/features/workspace";
@ -15,6 +16,27 @@ function MentionHoverCard({ type, id, children }: MentionHoverCardProps) {
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
if (type === "all") {
return (
<HoverCard>
<HoverCardTrigger render={<span />} className="cursor-default">
{children}
</HoverCardTrigger>
<HoverCardContent align="start" className="w-auto min-w-48 max-w-72">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Users className="h-4 w-4 text-primary" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium">All members</p>
<p className="text-xs text-muted-foreground">Notifies all workspace members</p>
</div>
</div>
</HoverCardContent>
</HoverCard>
);
}
if (type === "member") {
const member = members.find((m) => m.user_id === id);
if (!member) return <>{children}</>;

View file

@ -8,7 +8,7 @@ import {
useRef,
useState,
} from "react";
import { Hash } from "lucide-react";
import { Hash, Users } from "lucide-react";
import { ReactRenderer } from "@tiptap/react";
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
import { useWorkspaceStore } from "@/features/workspace";
@ -23,7 +23,7 @@ import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
export interface MentionItem {
id: string;
label: string;
type: "member" | "agent" | "issue";
type: "member" | "agent" | "issue" | "all";
/** Secondary text shown below the label (e.g. issue title) */
description?: string;
}
@ -99,7 +99,11 @@ const MentionList = forwardRef<MentionListRef, MentionListProps>(
}`}
onClick={() => selectItem(index)}
>
{item.type === "issue" ? (
{item.type === "all" ? (
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Users className="h-3 w-3" />
</span>
) : item.type === "issue" ? (
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Hash className="h-3 w-3" />
</span>
@ -137,6 +141,12 @@ export function createMentionSuggestion(): Omit<
const { issues } = useIssueStore.getState();
const q = query.toLowerCase();
// Show "All members" option when query is empty or matches "all"
const allItem: MentionItem[] =
"all members".includes(q) || "all".includes(q)
? [{ id: "all", label: "All members", type: "all" as const, description: "Notify all members" }]
: [];
const memberItems: MentionItem[] = members
.filter((m) => m.name.toLowerCase().includes(q))
.map((m) => ({
@ -162,7 +172,7 @@ export function createMentionSuggestion(): Omit<
description: i.title,
}));
return [...memberItems, ...agentItems, ...issueItems].slice(0, 10);
return [...allItem, ...memberItems, ...agentItems, ...issueItems].slice(0, 10);
},
render: () => {

View file

@ -103,7 +103,7 @@ const MentionExtension = Mention.configure({
return {
type: "mention",
raw: match[0],
attributes: { label: match[1], type: match[2], id: match[3] },
attributes: { label: match[1], type: match[2] ?? "member", id: match[3] },
};
},
},

View file

@ -99,9 +99,9 @@ function createComponents(
),
// Links: Make clickable with callbacks, or render as mention
a: ({ href, children }) => {
// Mention links: mention://member/id, mention://agent/id, mention://issue/id
// Mention links: mention://member/id, mention://agent/id, mention://issue/id, mention://all/all
if (href?.startsWith('mention://')) {
const mentionMatch = href.match(/^mention:\/\/(member|agent|issue)\/(.+)$/)
const mentionMatch = href.match(/^mention:\/\/(member|agent|issue|all)\/(.+)$/)
if (mentionMatch?.[1] === 'issue' && mentionMatch[2]) {
const label = typeof children === 'string' ? children : Array.isArray(children) ? children.join('') : undefined
return <IssueMentionCard issueId={mentionMatch[2]} fallbackLabel={label} />

View file

@ -14,8 +14,8 @@ import (
// mention represents a parsed @mention from markdown content (local alias).
type mention struct {
Type string // "member" or "agent"
ID string // user_id or agent_id
Type string // "member", "agent", or "all"
ID string // user_id, agent_id, or "all"
}
// statusLabels maps DB status values to human-readable labels for notifications.
@ -193,7 +193,8 @@ func notifyDirect(
}
// notifyMentionedMembers creates inbox items for each @mentioned member,
// excluding the actor and any IDs in the skip set.
// excluding the actor and any IDs in the skip set. When an @all mention is
// present, all workspace members are notified (excluding agents).
func notifyMentionedMembers(
bus *events.Bus,
queries *db.Queries,
@ -206,17 +207,40 @@ func notifyMentionedMembers(
skip map[string]bool,
details []byte,
) {
// Collect the set of member IDs to notify.
recipientIDs := map[string]bool{}
hasAll := false
for _, m := range mentions {
if m.Type != "member" {
if m.Type == "all" {
hasAll = true
continue
}
if m.ID == e.ActorID || skip[m.ID] {
if m.Type == "member" {
recipientIDs[m.ID] = true
}
}
// If @all is present, expand to all workspace members.
if hasAll {
members, err := queries.ListMembers(context.Background(), parseUUID(e.WorkspaceID))
if err != nil {
slog.Error("failed to list members for @all mention", "workspace_id", e.WorkspaceID, "error", err)
} else {
for _, m := range members {
recipientIDs[util.UUIDToString(m.UserID)] = true
}
}
}
for id := range recipientIDs {
if id == e.ActorID || skip[id] {
continue
}
item, err := queries.CreateInboxItem(context.Background(), db.CreateInboxItemParams{
WorkspaceID: parseUUID(e.WorkspaceID),
RecipientType: "member",
RecipientID: parseUUID(m.ID),
RecipientID: parseUUID(id),
Type: "mentioned",
Severity: "info",
IssueID: parseUUID(issueID),
@ -226,7 +250,7 @@ func notifyMentionedMembers(
Details: details,
})
if err != nil {
slog.Error("mention inbox creation failed", "mentioned_id", m.ID, "error", err)
slog.Error("mention inbox creation failed", "mentioned_id", id, "error", err)
continue
}
resp := inboxItemToResponse(item)

View file

@ -198,6 +198,9 @@ func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.I
}
assigneeID := uuidToString(issue.AssigneeID)
for _, m := range mentions {
if m.IsMentionAll() {
return false // @all includes everyone — allow trigger
}
if m.ID == assigneeID {
return false // Assignee is mentioned — allow trigger
}

View file

@ -4,12 +4,17 @@ import "regexp"
// Mention represents a parsed @mention from markdown content.
type Mention struct {
Type string // "member" or "agent"
ID string // user_id or agent_id
Type string // "member", "agent", or "all"
ID string // user_id, agent_id, or "all"
}
// MentionRe matches [@Label](mention://type/id) in markdown.
var MentionRe = regexp.MustCompile(`\[@[^\]]*\]\(mention://(member|agent)/([0-9a-fA-F-]+)\)`)
var MentionRe = regexp.MustCompile(`\[@[^\]]*\]\(mention://(member|agent|all)/([0-9a-fA-F-]+|all)\)`)
// IsMentionAll returns true if the mention is an @all mention.
func (m Mention) IsMentionAll() bool {
return m.Type == "all"
}
// ParseMentions extracts deduplicated mentions from markdown content.
func ParseMentions(content string) []Mention {
@ -26,3 +31,13 @@ func ParseMentions(content string) []Mention {
}
return result
}
// HasMentionAll returns true if any mention in the slice is an @all mention.
func HasMentionAll(mentions []Mention) bool {
for _, m := range mentions {
if m.IsMentionAll() {
return true
}
}
return false
}