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
129 lines
3.9 KiB
TypeScript
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>
|
|
);
|
|
}
|