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

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