diff --git a/apps/web/components/common/mention-hover-card.tsx b/apps/web/components/common/mention-hover-card.tsx index f0c3ef2c..a3986686 100644 --- a/apps/web/components/common/mention-hover-card.tsx +++ b/apps/web/components/common/mention-hover-card.tsx @@ -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 ( + + } className="cursor-default"> + {children} + + + + + + + + All members + Notifies all workspace members + + + + + ); + } + if (type === "member") { const member = members.find((m) => m.user_id === id); if (!member) return <>{children}>; diff --git a/apps/web/components/common/mention-suggestion.tsx b/apps/web/components/common/mention-suggestion.tsx index b0092f78..ad525038 100644 --- a/apps/web/components/common/mention-suggestion.tsx +++ b/apps/web/components/common/mention-suggestion.tsx @@ -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( }`} onClick={() => selectItem(index)} > - {item.type === "issue" ? ( + {item.type === "all" ? ( + + + + ) : item.type === "issue" ? ( @@ -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: () => { diff --git a/apps/web/components/common/rich-text-editor.tsx b/apps/web/components/common/rich-text-editor.tsx index a8687af5..a07adeed 100644 --- a/apps/web/components/common/rich-text-editor.tsx +++ b/apps/web/components/common/rich-text-editor.tsx @@ -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] }, }; }, }, diff --git a/apps/web/components/markdown/Markdown.tsx b/apps/web/components/markdown/Markdown.tsx index 351ac8c6..583780cf 100644 --- a/apps/web/components/markdown/Markdown.tsx +++ b/apps/web/components/markdown/Markdown.tsx @@ -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 diff --git a/server/cmd/server/notification_listeners.go b/server/cmd/server/notification_listeners.go index 1ad0ed92..5e5b78f3 100644 --- a/server/cmd/server/notification_listeners.go +++ b/server/cmd/server/notification_listeners.go @@ -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) diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index 215fedf4..c4f7690a 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -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 } diff --git a/server/internal/util/mention.go b/server/internal/util/mention.go index 83249b2c..d2557bdd 100644 --- a/server/internal/util/mention.go +++ b/server/internal/util/mention.go @@ -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 +}
All members
Notifies all workspace members