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] },
};
},
},