mirror of
https://github.com/block/buzz.git
synced 2026-11-04 00:12:02 +00:00
feat: quick-add agent popover with two entry points
Replace the direct + → full dialog flow with a lightweight Radix popover that shows available agents sorted by state (running > configured > catalog). One click to add. The full AddChannelBotDialog is still accessible via 'More options…' at the bottom of the popover. Two trigger points: - The existing + button in ChannelMembersBar now opens the popover - A new 'Add agent' button at the bottom of the sidebar Bots section The sidebar already had People/Bots section headers — the inline add affordance lives at the bottom of the Bots section. Actions: - Running agent not in channel → useAttachManagedAgentToChannelMutation - Persona without running agent → useCreateChannelManagedAgentMutation (leverages existing reuse logic from agentReuse.ts) The popover uses useBotRecents for smart ordering and pickBotName for instance naming. Force-new-instance stays buried in the full dialog.
This commit is contained in:
@@ -14,6 +14,7 @@ import type { Channel } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { AddChannelBotDialog } from "./AddChannelBotDialog";
|
||||
import { QuickAddAgentPopover } from "./QuickAddAgentPopover";
|
||||
|
||||
type ChannelMembersBarProps = {
|
||||
channel: Channel;
|
||||
@@ -29,6 +30,7 @@ export function ChannelMembersBar({
|
||||
onToggleMembers,
|
||||
}: ChannelMembersBarProps) {
|
||||
const [isAddBotOpen, setIsAddBotOpen] = React.useState(false);
|
||||
const [isQuickAddOpen, setIsQuickAddOpen] = React.useState(false);
|
||||
const { startHuddle, isStarting: isStartingHuddle } = useHuddle();
|
||||
const queryClient = useQueryClient();
|
||||
const membersQuery = useChannelMembersQuery(channel.id);
|
||||
@@ -73,6 +75,7 @@ export function ChannelMembersBar({
|
||||
|
||||
previousChannelIdRef.current = channel.id;
|
||||
setIsAddBotOpen(false);
|
||||
setIsQuickAddOpen(false);
|
||||
}, [channel.id]);
|
||||
|
||||
const dialogErrorMessage =
|
||||
@@ -87,20 +90,24 @@ export function ChannelMembersBar({
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
aria-label="Add agent"
|
||||
className="h-7 w-7 rounded-full"
|
||||
data-testid="channel-add-bot-trigger"
|
||||
disabled={!canAddAgents}
|
||||
onClick={() => {
|
||||
setIsAddBotOpen(true);
|
||||
}}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
<QuickAddAgentPopover
|
||||
channelId={channel.id}
|
||||
open={isQuickAddOpen}
|
||||
onOpenChange={setIsQuickAddOpen}
|
||||
onMoreOptions={() => setIsAddBotOpen(true)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Add agent"
|
||||
className="h-7 w-7 rounded-full"
|
||||
data-testid="channel-add-bot-trigger"
|
||||
disabled={!canAddAgents}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</QuickAddAgentPopover>
|
||||
|
||||
<HuddleIndicator
|
||||
className="h-7 w-7"
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { Plus } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useAddChannelMembersMutation,
|
||||
useChannelMembersQuery,
|
||||
} from "@/features/channels/hooks";
|
||||
import { useUpdateManagedAgentMutation } from "@/features/agents/hooks";
|
||||
import {
|
||||
useAcpProvidersQuery,
|
||||
useBackendProvidersQuery,
|
||||
useManagedAgentsQuery,
|
||||
useRelayAgentsQuery,
|
||||
useUpdateManagedAgentMutation,
|
||||
} from "@/features/agents/hooks";
|
||||
import { CreateAgentRespondToField } from "@/features/agents/ui/RespondToField";
|
||||
import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers";
|
||||
import {
|
||||
@@ -41,6 +48,8 @@ import { MembersSidebarAgentControls } from "./MembersSidebarAgentControls";
|
||||
import { ChannelMemberInviteCard } from "./ChannelMemberInviteCard";
|
||||
import { MembersSidebarMemberCard } from "./MembersSidebarMemberCard";
|
||||
import { useMembersSidebarActions } from "./useMembersSidebarActions";
|
||||
import { AddChannelBotDialog } from "./AddChannelBotDialog";
|
||||
import { QuickAddAgentPopover } from "./QuickAddAgentPopover";
|
||||
|
||||
type MembersSidebarProps = {
|
||||
channel: Channel | null;
|
||||
@@ -61,6 +70,31 @@ export function MembersSidebar({
|
||||
const queryClient = useQueryClient();
|
||||
const membersQuery = useChannelMembersQuery(channelId, open);
|
||||
const addMembersMutation = useAddChannelMembersMutation(channelId);
|
||||
const providersQuery = useAcpProvidersQuery();
|
||||
const backendProvidersQuery = useBackendProvidersQuery();
|
||||
const sidebarManagedAgentsQuery = useManagedAgentsQuery();
|
||||
const sidebarRelayAgentsQuery = useRelayAgentsQuery();
|
||||
const sidebarProviders = React.useMemo(
|
||||
() =>
|
||||
[...(providersQuery.data ?? [])].sort((left, right) => {
|
||||
const leftPriority = left.id === "goose" ? 0 : 1;
|
||||
const rightPriority = right.id === "goose" ? 0 : 1;
|
||||
if (leftPriority !== rightPriority) {
|
||||
return leftPriority - rightPriority;
|
||||
}
|
||||
return left.label.localeCompare(right.label);
|
||||
}),
|
||||
[providersQuery.data],
|
||||
);
|
||||
const sidebarDialogErrorMessage =
|
||||
providersQuery.error instanceof Error
|
||||
? providersQuery.error.message
|
||||
: sidebarManagedAgentsQuery.error instanceof Error
|
||||
? sidebarManagedAgentsQuery.error.message
|
||||
: sidebarRelayAgentsQuery.error instanceof Error
|
||||
? sidebarRelayAgentsQuery.error.message
|
||||
: null;
|
||||
|
||||
const changeRoleMutation = useMutation({
|
||||
mutationFn: async ({ pubkey, role }: { pubkey: string; role: string }) => {
|
||||
if (!channelId) throw new Error("No channel selected.");
|
||||
@@ -98,6 +132,16 @@ export function MembersSidebar({
|
||||
selfMember?.role === "owner" || selfMember?.role === "admin";
|
||||
const isArchived =
|
||||
channel?.archivedAt !== null && channel?.archivedAt !== undefined;
|
||||
const canAddAgents =
|
||||
channel?.channelType !== "dm" &&
|
||||
!isArchived &&
|
||||
(channel?.visibility === "open" || canManageMembers);
|
||||
|
||||
const [isSidebarQuickAddOpen, setIsSidebarQuickAddOpen] =
|
||||
React.useState(false);
|
||||
const [isSidebarFullDialogOpen, setIsSidebarFullDialogOpen] =
|
||||
React.useState(false);
|
||||
|
||||
const managedAgentByPubkey = React.useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
@@ -298,6 +342,26 @@ export function MembersSidebar({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{canAddAgents ? (
|
||||
<QuickAddAgentPopover
|
||||
channelId={channelId}
|
||||
open={isSidebarQuickAddOpen}
|
||||
onOpenChange={setIsSidebarQuickAddOpen}
|
||||
onMoreOptions={() => setIsSidebarFullDialogOpen(true)}
|
||||
>
|
||||
<Button
|
||||
aria-label="Add agent to channel"
|
||||
className="mt-2 h-8 w-full justify-start gap-2 text-xs text-muted-foreground"
|
||||
data-testid="sidebar-add-agent-trigger"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add agent
|
||||
</Button>
|
||||
</QuickAddAgentPopover>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{changeRoleError ? (
|
||||
@@ -319,6 +383,18 @@ export function MembersSidebar({
|
||||
}}
|
||||
open={editRespondToAgent !== null}
|
||||
/>
|
||||
{canAddAgents ? (
|
||||
<AddChannelBotDialog
|
||||
backendProviders={backendProvidersQuery.data ?? []}
|
||||
backendProvidersLoading={backendProvidersQuery.isLoading}
|
||||
channelId={channelId}
|
||||
onOpenChange={setIsSidebarFullDialogOpen}
|
||||
open={isSidebarFullDialogOpen}
|
||||
providers={sidebarProviders}
|
||||
providersErrorMessage={sidebarDialogErrorMessage}
|
||||
providersLoading={providersQuery.isLoading}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
396
desktop/src/features/channels/ui/QuickAddAgentPopover.tsx
Normal file
396
desktop/src/features/channels/ui/QuickAddAgentPopover.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
import { Check, Settings2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
useAcpProvidersQuery,
|
||||
useAttachManagedAgentToChannelMutation,
|
||||
useCreateChannelManagedAgentMutation,
|
||||
useManagedAgentsQuery,
|
||||
usePersonasQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import { useChannelMembersQuery } from "@/features/channels/hooks";
|
||||
import { getActivePersonas } from "@/features/agents/lib/catalog";
|
||||
import { resolvePersonaProvider } from "@/features/agents/lib/resolvePersonaProvider";
|
||||
import { pickBotName } from "@/features/agents/lib/pickBotName";
|
||||
import { useBotRecents } from "@/features/agents/lib/useBotRecents";
|
||||
import type { AgentPersona, ManagedAgent } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/shared/ui/popover";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type QuickAddAgentItem = {
|
||||
kind: "running-available" | "running-in-channel" | "persona";
|
||||
agent?: ManagedAgent;
|
||||
persona?: AgentPersona;
|
||||
label: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
|
||||
type QuickAddAgentPopoverProps = {
|
||||
channelId: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onMoreOptions: () => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function QuickAddAgentPopover({
|
||||
channelId,
|
||||
open,
|
||||
onOpenChange,
|
||||
onMoreOptions,
|
||||
children,
|
||||
}: QuickAddAgentPopoverProps) {
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const personasQuery = usePersonasQuery();
|
||||
const providersQuery = useAcpProvidersQuery();
|
||||
const membersQuery = useChannelMembersQuery(channelId, open);
|
||||
const attachMutation = useAttachManagedAgentToChannelMutation(channelId);
|
||||
const createMutation = useCreateChannelManagedAgentMutation(channelId);
|
||||
const { recentIds, pushRecent } = useBotRecents();
|
||||
|
||||
const [pendingKey, setPendingKey] = React.useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
|
||||
|
||||
const managedAgents = managedAgentsQuery.data ?? [];
|
||||
const personas = React.useMemo(
|
||||
() => getActivePersonas(personasQuery.data ?? []),
|
||||
[personasQuery.data],
|
||||
);
|
||||
const providers = providersQuery.data ?? [];
|
||||
const defaultProvider = providers[0] ?? null;
|
||||
const members = membersQuery.data ?? [];
|
||||
|
||||
const channelMemberPubkeys = React.useMemo(
|
||||
() => new Set(members.map((m) => normalizePubkey(m.pubkey))),
|
||||
[members],
|
||||
);
|
||||
|
||||
// Build the sorted item list
|
||||
const items: QuickAddAgentItem[] = React.useMemo(() => {
|
||||
const result: QuickAddAgentItem[] = [];
|
||||
|
||||
// Running agents not in this channel
|
||||
const runningAvailable = managedAgents.filter(
|
||||
(agent) =>
|
||||
(agent.status === "running" || agent.status === "deployed") &&
|
||||
!channelMemberPubkeys.has(normalizePubkey(agent.pubkey)),
|
||||
);
|
||||
|
||||
// Running agents already in this channel
|
||||
const runningInChannel = managedAgents.filter(
|
||||
(agent) =>
|
||||
(agent.status === "running" || agent.status === "deployed") &&
|
||||
channelMemberPubkeys.has(normalizePubkey(agent.pubkey)),
|
||||
);
|
||||
|
||||
// Personas that don't have a running agent yet (configured but not running)
|
||||
// Exclude personas whose agent is already in the channel
|
||||
const personaIdsInChannel = new Set(
|
||||
managedAgents
|
||||
.filter((agent) =>
|
||||
channelMemberPubkeys.has(normalizePubkey(agent.pubkey)),
|
||||
)
|
||||
.map((agent) => agent.personaId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
|
||||
const availablePersonas = personas.filter(
|
||||
(persona) =>
|
||||
!personaIdsInChannel.has(persona.id) &&
|
||||
!runningAvailable.some((agent) => agent.personaId === persona.id),
|
||||
);
|
||||
|
||||
// Sort running-available by recency
|
||||
const sortedRunningAvailable = [...runningAvailable].sort((a, b) => {
|
||||
const aPersonaIdx = a.personaId
|
||||
? recentIds.indexOf(a.personaId)
|
||||
: -1;
|
||||
const bPersonaIdx = b.personaId
|
||||
? recentIds.indexOf(b.personaId)
|
||||
: -1;
|
||||
const aScore = aPersonaIdx >= 0 ? aPersonaIdx : 999;
|
||||
const bScore = bPersonaIdx >= 0 ? bPersonaIdx : 999;
|
||||
return aScore - bScore;
|
||||
});
|
||||
|
||||
for (const agent of sortedRunningAvailable) {
|
||||
const persona = agent.personaId
|
||||
? personas.find((p) => p.id === agent.personaId)
|
||||
: undefined;
|
||||
result.push({
|
||||
kind: "running-available",
|
||||
agent,
|
||||
persona,
|
||||
label: agent.name,
|
||||
avatarUrl: persona?.avatarUrl ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const agent of runningInChannel) {
|
||||
const persona = agent.personaId
|
||||
? personas.find((p) => p.id === agent.personaId)
|
||||
: undefined;
|
||||
result.push({
|
||||
kind: "running-in-channel",
|
||||
agent,
|
||||
persona,
|
||||
label: agent.name,
|
||||
avatarUrl: persona?.avatarUrl ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort available personas: recents first, then alphabetical
|
||||
const sortedPersonas = [...availablePersonas].sort((a, b) => {
|
||||
const aIdx = recentIds.indexOf(a.id);
|
||||
const bIdx = recentIds.indexOf(b.id);
|
||||
if (aIdx >= 0 && bIdx >= 0) return aIdx - bIdx;
|
||||
if (aIdx >= 0) return -1;
|
||||
if (bIdx >= 0) return 1;
|
||||
return a.displayName.localeCompare(b.displayName);
|
||||
});
|
||||
|
||||
for (const persona of sortedPersonas) {
|
||||
result.push({
|
||||
kind: "persona",
|
||||
persona,
|
||||
label: persona.displayName,
|
||||
avatarUrl: persona.avatarUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [
|
||||
managedAgents,
|
||||
personas,
|
||||
channelMemberPubkeys,
|
||||
recentIds,
|
||||
]);
|
||||
|
||||
// Reset state when popover closes
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setPendingKey(null);
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
async function handleAddRunningAgent(agent: ManagedAgent) {
|
||||
const key = `agent:${agent.pubkey}`;
|
||||
setPendingKey(key);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
await attachMutation.mutateAsync({ agent, ensureRunning: true });
|
||||
if (agent.personaId) pushRecent(agent.personaId);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setErrorMessage(
|
||||
err instanceof Error ? err.message : "Failed to add agent.",
|
||||
);
|
||||
setPendingKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddPersona(persona: AgentPersona) {
|
||||
const key = `persona:${persona.id}`;
|
||||
setPendingKey(key);
|
||||
setErrorMessage(null);
|
||||
|
||||
const { provider } = resolvePersonaProvider(
|
||||
persona.provider,
|
||||
providers,
|
||||
defaultProvider,
|
||||
);
|
||||
|
||||
if (!provider) {
|
||||
setErrorMessage("No agent runtime available.");
|
||||
setPendingKey(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick a name from the persona's pool
|
||||
const usedNames = new Set(managedAgents.map((a) => a.name));
|
||||
const instanceName = pickBotName(persona.namePool, usedNames);
|
||||
|
||||
try {
|
||||
await createMutation.mutateAsync({
|
||||
provider,
|
||||
name: instanceName,
|
||||
systemPrompt: persona.systemPrompt,
|
||||
avatarUrl: persona.avatarUrl ?? undefined,
|
||||
personaId: persona.id,
|
||||
model: persona.model ?? undefined,
|
||||
});
|
||||
pushRecent(persona.id);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setErrorMessage(
|
||||
err instanceof Error ? err.message : "Failed to add agent.",
|
||||
);
|
||||
setPendingKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleItemClick(item: QuickAddAgentItem) {
|
||||
if (item.kind === "running-in-channel") return;
|
||||
if (pendingKey) return;
|
||||
|
||||
if (item.kind === "running-available" && item.agent) {
|
||||
void handleAddRunningAgent(item.agent);
|
||||
} else if (item.kind === "persona" && item.persona) {
|
||||
void handleAddPersona(item.persona);
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading =
|
||||
managedAgentsQuery.isLoading ||
|
||||
personasQuery.isLoading ||
|
||||
providersQuery.isLoading;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-72 p-0"
|
||||
sideOffset={6}
|
||||
>
|
||||
<div className="flex max-h-80 flex-col">
|
||||
<div className="border-b px-3 py-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Add agent
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Spinner className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
No agents available.
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1">
|
||||
{items.map((item) => {
|
||||
const itemKey =
|
||||
item.kind === "persona"
|
||||
? `persona:${item.persona?.id}`
|
||||
: `agent:${item.agent?.pubkey}`;
|
||||
const isInChannel = item.kind === "running-in-channel";
|
||||
const isItemPending = pendingKey === itemKey;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm transition-colors",
|
||||
isInChannel
|
||||
? "cursor-default opacity-50"
|
||||
: "cursor-pointer hover:bg-accent focus-visible:bg-accent focus-visible:outline-none",
|
||||
isItemPending && "pointer-events-none opacity-60",
|
||||
)}
|
||||
disabled={isInChannel || Boolean(pendingKey)}
|
||||
key={itemKey}
|
||||
onClick={() => handleItemClick(item)}
|
||||
type="button"
|
||||
>
|
||||
<QuickAddAgentAvatar
|
||||
avatarUrl={item.avatarUrl}
|
||||
label={item.label}
|
||||
isRunning={item.kind !== "persona"}
|
||||
/>
|
||||
<span className="flex-1 truncate font-medium">
|
||||
{item.label}
|
||||
</span>
|
||||
{isInChannel ? (
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : item.kind === "running-available" ? (
|
||||
<span className="shrink-0 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
|
||||
running
|
||||
</span>
|
||||
) : null}
|
||||
{isItemPending ? (
|
||||
<Spinner className="h-3.5 w-3.5 shrink-0 text-primary" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="border-t px-3 py-2">
|
||||
<p className="text-xs text-destructive">{errorMessage}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="border-t">
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
onMoreOptions();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
<span>More options…</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Avatar helper ─────────────────────────────────────────────────────────────
|
||||
|
||||
function QuickAddAgentAvatar({
|
||||
avatarUrl,
|
||||
label,
|
||||
isRunning,
|
||||
}: {
|
||||
avatarUrl: string | null;
|
||||
label: string;
|
||||
isRunning: boolean;
|
||||
}) {
|
||||
const initials = label
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border/50 bg-muted/30">
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
alt={label}
|
||||
className="h-full w-full rounded-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
src={rewriteRelayUrl(avatarUrl)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
{initials}
|
||||
</span>
|
||||
)}
|
||||
{isRunning ? (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-background bg-emerald-500" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user