feat(mentions): support @all to mention all workspace members

Add @all mention type that notifies all workspace members (excluding
agents). Includes backend parsing, notification expansion to all members,
and frontend UI with autocomplete suggestion, rendering, and hover card.
This commit is contained in:
Jiayuan 2026-04-01 20:14:25 +08:00
parent e68091e4a8
commit 095b7f8185
7 changed files with 91 additions and 17 deletions

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
}