gitdataai/src/components/room/RoomParticipantsPanel.tsx
ZhenYi 26682973e7 feat(room): redesign mention system with AST-based format
Backend:
- Add MENTION_TAG_RE matching new `<mention type="..." id="...">label</mention>` format
- Extend extract_mentions() and resolve_mentions() to parse new format (legacy backward-compatible)

Frontend:
- New src/lib/mention-ast.ts: AST types (TextNode, MentionNode, AiActionNode),
  parse() and serialize() functions for AST↔HTML conversion
- MentionPopover: load @repository: from project repos, @ai: from room_ai configs
  (not room members); output new HTML format with ID instead of label
- MessageMentions: use AST parse() for rendering (falls back to legacy parser)
- ChatInputArea: insertMention now produces `<mention type="user" id="...">label</mention>`
- RoomParticipantsPanel: onMention passes member UUID to insertMention
- RoomContext: add projectRepos and roomAiConfigs for mention data sources
2026-04-17 23:43:26 +08:00

129 lines
3.9 KiB
TypeScript

import { useMemo, memo } from 'react';
import type { RoomMemberResponse } from '@/client';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import { Loader2, Shield, UserRound } from 'lucide-react';
import type { ReactNode } from 'react';
interface RoomParticipantsPanelProps {
members: RoomMemberResponse[];
membersLoading: boolean;
onMention?: (id: string, label: string, type: 'user' | 'ai') => void;
}
export const RoomParticipantsPanel = memo(function RoomParticipantsPanel({
members,
membersLoading,
onMention,
}: RoomParticipantsPanelProps) {
// Separate owners/admins from regular members
const admins = useMemo(
() => members.filter((m) => m.role === 'owner' || m.role === 'admin'),
[members],
);
const regular = useMemo(
() => members.filter((m) => m.role === 'member' || m.role === 'guest'),
[members],
);
return (
<aside className="hidden w-[360px] border-l border-border/70 bg-card/50 xl:flex xl:flex-col">
<header className="flex h-12 items-center border-b border-border/70 px-3">
<p className="text-sm font-semibold text-foreground">Members</p>
</header>
{membersLoading ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : (
<ScrollArea className="flex-1">
<div className="space-y-5 p-3">
<ParticipantSection
title={`Managers — ${admins.length}`}
icon={<Shield className="h-3.5 w-3.5" />}
>
{admins.map((member) => (
<ParticipantRow
key={member.user}
member={member}
onMention={onMention}
/>
))}
</ParticipantSection>
<ParticipantSection
title={`Members — ${regular.length}`}
icon={<UserRound className="h-3.5 w-3.5" />}
>
{regular.map((member) => (
<ParticipantRow
key={member.user}
member={member}
onMention={onMention}
/>
))}
</ParticipantSection>
</div>
</ScrollArea>
)}
</aside>
);
});
function ParticipantSection({
title,
icon,
children,
}: {
title: string;
icon: ReactNode;
children: ReactNode;
}) {
return (
<section>
<h3 className="mb-2 flex items-center gap-1.5 px-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{icon}
{title}
</h3>
<div className="space-y-1">{children}</div>
</section>
);
}
function ParticipantRow({
member,
onMention,
}: {
member: RoomMemberResponse;
onMention?: (id: string, label: string, type: 'user' | 'ai') => void;
}) {
const username = member.user_info?.username ?? member.user;
const avatarUrl = member.user_info?.avatar_url;
const displayName = username;
const initial = (displayName?.[0] ?? '?').toUpperCase();
return (
<button
type="button"
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/60',
)}
onClick={() => onMention?.(member.user, username, 'user')}
disabled={!onMention}
>
<Avatar className="h-7 w-7">
{avatarUrl ? (
<AvatarImage src={avatarUrl} alt={displayName} />
) : null}
<AvatarFallback className="text-xs">{initial}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{displayName}</p>
<p className="truncate text-[11px] text-muted-foreground">@{displayName}</p>
</div>
</button>
);
}