Merge pull request #304 from multica-ai/agent/lambda/95b033ec
feat(mentions): support @all to mention all workspace members
This commit is contained in:
commit
6b9341f7ad
7 changed files with 91 additions and 17 deletions
|
|
@ -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}</>;
|
||||
|
|
|
|||
|
|
@ -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: () => {
|
||||
|
|
|
|||
|
|
@ -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] },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue