feat: inline quick-add bot bar + identicons for numbered bot copies (#255)

This commit is contained in:
Bradley Axen
2026-04-07 11:26:43 -07:00
committed by GitHub
parent 35588a0594
commit 3da5e5cafe
10 changed files with 464 additions and 87 deletions

View File

@@ -44,6 +44,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"jdenticon": "^3.3.0",
"lucide-react": "^0.577.0",
"react": "^19.1.0",
"react-diff-view": "^3.3.2",

53
desktop/pnpm-lock.yaml generated
View File

@@ -68,6 +68,9 @@ importers:
emoji-mart:
specifier: ^5.6.0
version: 5.6.0
jdenticon:
specifier: ^3.3.0
version: 3.3.0
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@19.2.4)
@@ -119,7 +122,7 @@ importers:
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: ^4.6.0
version: 4.7.0(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3))
version: 4.7.0(vite@7.3.1(@types/node@25.5.2)(jiti@1.21.7)(yaml@2.8.3))
autoprefixer:
specifier: ^10.4.27
version: 10.4.27(postcss@8.5.8)
@@ -140,7 +143,7 @@ importers:
version: 5.8.3
vite:
specifier: ^7.0.4
version: 7.3.1(jiti@1.21.7)(yaml@2.8.3)
version: 7.3.1(@types/node@25.5.2)(jiti@1.21.7)(yaml@2.8.3)
packages:
@@ -947,7 +950,6 @@ packages:
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
@@ -958,61 +960,51 @@ packages:
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.59.0':
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.59.0':
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.59.0':
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.59.0':
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
@@ -1213,6 +1205,9 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@25.5.2':
resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -1285,6 +1280,9 @@ packages:
caniuse-lite@1.0.30001777:
resolution: {integrity: sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==}
canvas-renderer@2.2.1:
resolution: {integrity: sha512-RrBgVL5qCEDIXpJ6NrzyRNoTnXxYarqm/cS/W6ERhUJts5UQtt/XPEosGN3rqUkZ4fjBArlnCbsISJ+KCFnIAg==}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -1501,6 +1499,11 @@ packages:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
jdenticon@3.3.0:
resolution: {integrity: sha512-DhuBRNRIybGPeAjMjdHbkIfiwZCCmf8ggu7C49jhp6aJ7DYsZfudnvnTY5/1vgUhrGA7JaDAx1WevnpjCPvaGg==}
engines: {node: '>=6.4.0'}
hasBin: true
jiti@1.21.7:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true
@@ -2003,6 +2006,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -3035,6 +3041,10 @@ snapshots:
'@types/ms@2.1.0': {}
'@types/node@25.5.2':
dependencies:
undici-types: 7.18.2
'@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
'@types/react': 19.2.14
@@ -3049,7 +3059,7 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
'@vitejs/plugin-react@4.7.0(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3))':
'@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@25.5.2)(jiti@1.21.7)(yaml@2.8.3))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
@@ -3057,7 +3067,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 7.3.1(jiti@1.21.7)(yaml@2.8.3)
vite: 7.3.1(@types/node@25.5.2)(jiti@1.21.7)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
@@ -3105,6 +3115,10 @@ snapshots:
caniuse-lite@1.0.30001777: {}
canvas-renderer@2.2.1:
dependencies:
'@types/node': 25.5.2
ccount@2.0.1: {}
character-entities-html4@2.1.0: {}
@@ -3329,6 +3343,10 @@ snapshots:
is-plain-obj@4.1.0: {}
jdenticon@3.3.0:
dependencies:
canvas-renderer: 2.2.1
jiti@1.21.7: {}
js-tokens@4.0.0: {}
@@ -4099,6 +4117,8 @@ snapshots:
typescript@5.8.3: {}
undici-types@7.18.2: {}
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3
@@ -4165,7 +4185,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@7.3.1(jiti@1.21.7)(yaml@2.8.3):
vite@7.3.1(@types/node@25.5.2)(jiti@1.21.7)(yaml@2.8.3):
dependencies:
esbuild: 0.27.3
fdir: 6.5.0(picomatch@4.0.3)
@@ -4174,6 +4194,7 @@ snapshots:
rollup: 4.59.0
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 25.5.2
fsevents: 2.3.3
jiti: 1.21.7
yaml: 2.8.3

View File

@@ -0,0 +1,39 @@
import * as React from "react";
const STORAGE_KEY = "sprout:bot-recents";
const MAX_RECENTS = 8;
// Default persona display names to seed the list when empty.
// These are resolved to IDs by the consumer.
export const DEFAULT_PERSONA_NAMES = ["Solo", "Ralph", "Scout"] as const;
export function useBotRecents(): {
recentIds: string[];
pushRecent: (personaId: string) => void;
} {
const [recentIds, setRecentIds] = React.useState<string[]>(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as string[]) : [];
} catch {
return [];
}
});
const pushRecent = React.useCallback((personaId: string) => {
setRecentIds((prev) => {
const next = [personaId, ...prev.filter((id) => id !== personaId)].slice(
0,
MAX_RECENTS,
);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
// localStorage full — ignore
}
return next;
});
}, []);
return { recentIds, pushRecent };
}

View File

@@ -5,11 +5,18 @@ import {
useAcpProvidersQuery,
useBackendProvidersQuery,
useManagedAgentsQuery,
usePersonasQuery,
useRelayAgentsQuery,
} from "@/features/agents/hooks";
import {
useBotRecents,
DEFAULT_PERSONA_NAMES,
} from "@/features/agents/lib/useBotRecents";
import { useChannelMembersQuery } from "@/features/channels/hooks";
import { QuickBotBar } from "@/features/channels/ui/QuickBotBar";
import { useQuickBotDrop } from "@/features/channels/ui/useQuickBotDrop";
import { CreateWorkflowDialog } from "@/features/workflows/ui/CreateWorkflowDialog";
import type { Channel } from "@/shared/api/types";
import type { AgentPersona, Channel } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import { AddChannelBotDialog } from "./AddChannelBotDialog";
@@ -56,6 +63,77 @@ export function ChannelMembersBar({
members.find(
(member) => normalizePubkey(member.pubkey) === normalizedCurrentPubkey,
) ?? null;
const personasQuery2 = usePersonasQuery();
const allPersonas = personasQuery2.data ?? [];
const { recentIds, pushRecent } = useBotRecents();
const quickDrop = useQuickBotDrop(channel.id);
// Track in-flight instance numbers so rapid clicks don't produce duplicates.
// Cleared when the members query refetches with the new member.
const inflightCountRef = React.useRef<Record<string, number>>({});
// Resolve the 3 personas to show in the quick bar.
// Use recents if available, otherwise fall back to default names.
const quickPersonas = React.useMemo(() => {
if (allPersonas.length === 0) return [];
const resolved: typeof allPersonas = [];
if (recentIds.length > 0) {
for (const id of recentIds) {
const found = allPersonas.find((p) => p.id === id);
if (found) resolved.push(found);
if (resolved.length >= 3) break;
}
}
if (resolved.length < 3) {
for (const name of DEFAULT_PERSONA_NAMES) {
if (resolved.length >= 3) break;
const found = allPersonas.find(
(p) =>
p.displayName.toLowerCase() === name.toLowerCase() &&
!resolved.some((r) => r.id === p.id),
);
if (found) resolved.push(found);
}
}
// Reset in-flight counts when members list updates (the new bot appeared).
inflightCountRef.current = {};
// Compute instance names from current members
return resolved.map((persona) => {
const prefix = `${persona.displayName}::`;
let maxNum = 0;
for (const member of members) {
const label = member.displayName ?? "";
if (label.startsWith(prefix)) {
const num = Number.parseInt(label.slice(prefix.length), 10);
if (!Number.isNaN(num) && num > maxNum) maxNum = num;
}
}
const inflight = inflightCountRef.current[persona.id] ?? 0;
const next = maxNum + 1 + inflight;
return {
persona,
instanceName: `${persona.displayName}::${String(next).padStart(2, "0")}`,
};
});
}, [allPersonas, recentIds, members]);
const addBot = quickDrop.addBot;
const handleQuickAdd = React.useCallback(
async (persona: AgentPersona, instanceName: string) => {
// Optimistically bump the in-flight counter to avoid duplicate names.
inflightCountRef.current[persona.id] =
(inflightCountRef.current[persona.id] ?? 0) + 1;
pushRecent(persona.id);
await addBot(persona, instanceName);
},
[pushRecent, addBot],
);
const canManageMembers =
selfMember?.role === "owner" || selfMember?.role === "admin";
const canAddAgents =
@@ -86,20 +164,29 @@ export function ChannelMembersBar({
return (
<React.Fragment>
<div className="flex items-center gap-2">
<Button
aria-label="Add agent"
className="h-9 w-9 rounded-full"
data-testid="channel-add-bot-trigger"
disabled={!canAddAgents}
onClick={() => {
setIsAddBotOpen(true);
}}
size="icon"
type="button"
variant="outline"
>
<Plus className="h-4 w-4" />
</Button>
<div className="group/quick flex items-center">
{canAddAgents ? (
<QuickBotBar
personas={quickPersonas}
pending={quickDrop.pending}
onAdd={handleQuickAdd}
/>
) : null}
<Button
aria-label="Add agent"
className="h-9 w-9 rounded-full"
data-testid="channel-add-bot-trigger"
disabled={!canAddAgents}
onClick={() => {
setIsAddBotOpen(true);
}}
size="icon"
type="button"
variant="outline"
>
<Plus className="h-4 w-4" />
</Button>
</div>
<Button
aria-label="Create workflow"

View File

@@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader";
import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers";
import { useChannelMembersQuery } from "@/features/channels/hooks";
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
import { MembersSidebar } from "@/features/channels/ui/MembersSidebar";
import {
@@ -158,6 +159,8 @@ export function ChannelScreen({
),
[currentProfile, messageProfilesQuery.data?.profiles],
);
const channelMembersQuery = useChannelMembersQuery(activeChannel?.id ?? null);
const channelMembers = channelMembersQuery.data;
const timelineMessages = React.useMemo(
() =>
formatTimelineMessages(
@@ -166,9 +169,11 @@ export function ChannelScreen({
currentPubkey,
currentProfile?.avatarUrl ?? null,
messageProfiles,
channelMembers,
),
[
activeChannel,
channelMembers,
currentProfile?.avatarUrl,
currentPubkey,
messageProfiles,

View File

@@ -0,0 +1,98 @@
import * as React from "react";
import { Loader2 } from "lucide-react";
import type { AgentPersona } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
type QuickBotBarProps = {
personas: Array<{
persona: AgentPersona;
instanceName: string;
}>;
pending: boolean;
onAdd: (persona: AgentPersona, instanceName: string) => void;
};
export function QuickBotBar({ personas, pending, onAdd }: QuickBotBarProps) {
const [pendingId, setPendingId] = React.useState<string | null>(null);
// Clear pending state when the mutation finishes
React.useEffect(() => {
if (!pending && pendingId) {
setPendingId(null);
}
}, [pending, pendingId]);
if (personas.length === 0) return null;
return (
<div
className="flex items-center"
role="toolbar"
aria-label="Quick add bots"
>
<div
className={cn(
"flex items-center gap-1 overflow-hidden transition-all duration-200 ease-out",
"max-w-0 opacity-0 group-hover/quick:mr-1 group-hover/quick:max-w-[120px] group-hover/quick:opacity-100",
)}
>
{personas.slice(0, 3).map(({ persona, instanceName }) => {
const initials = persona.displayName
.split(" ")
.map((p) => p[0])
.join("")
.slice(0, 2)
.toUpperCase();
const isThisPending = pendingId === persona.id;
return (
<Tooltip key={persona.id}>
<TooltipTrigger asChild>
<button
aria-label={`Add ${persona.displayName}`}
className={cn(
"relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full",
"border border-border/50 shadow-sm",
"transition-transform duration-150 hover:scale-110",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isThisPending && "pointer-events-none opacity-60",
)}
disabled={pending}
onClick={() => {
setPendingId(persona.id);
onAdd(persona, instanceName);
}}
type="button"
>
{persona.avatarUrl ? (
<img
alt={persona.displayName}
className="h-full w-full rounded-full object-cover"
referrerPolicy="no-referrer"
src={rewriteRelayUrl(persona.avatarUrl)}
/>
) : (
<span className="text-[9px] font-semibold text-primary">
{initials}
</span>
)}
{isThisPending ? (
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-background/70">
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
</div>
) : null}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Add {persona.displayName} → {instanceName}
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import * as React from "react";
import {
useAcpProvidersQuery,
useCreateChannelManagedAgentMutation,
} from "@/features/agents/hooks";
import { resolvePersonaProvider } from "@/features/agents/lib/resolvePersonaProvider";
import type { AgentPersona } from "@/shared/api/types";
type QuickBotDropState = {
pending: boolean;
error: string | null;
};
/**
* Handles creating a new managed agent from a persona with a given instance name.
*/
export function useQuickBotDrop(channelId: string | null) {
const createMutation = useCreateChannelManagedAgentMutation(channelId);
const providersQuery = useAcpProvidersQuery();
const [state, setState] = React.useState<QuickBotDropState>({
pending: false,
error: null,
});
const providers = providersQuery.data ?? [];
const defaultProvider = providers[0] ?? null;
const addBot = React.useCallback(
async (persona: AgentPersona, instanceName: string) => {
if (state.pending || !channelId) return;
setState({ pending: true, error: null });
try {
const { provider } = resolvePersonaProvider(
persona.provider,
providers,
defaultProvider,
);
if (!provider) {
setState({
pending: false,
error: "No agent runtime available.",
});
return;
}
await createMutation.mutateAsync({
provider,
name: instanceName,
systemPrompt: persona.systemPrompt,
avatarUrl: persona.avatarUrl ?? undefined,
personaId: persona.id,
model: persona.model ?? undefined,
});
setState({ pending: false, error: null });
} catch (err) {
setState({
pending: false,
error: err instanceof Error ? err.message : "Failed to create agent.",
});
}
},
[channelId, createMutation, defaultProvider, providers, state.pending],
);
return { ...state, addBot };
}

View File

@@ -1,4 +1,4 @@
import type { Channel, RelayEvent } from "@/shared/api/types";
import type { Channel, ChannelMember, RelayEvent } from "@/shared/api/types";
import type {
TimelineMessage,
@@ -113,8 +113,15 @@ export function formatTimelineMessages(
currentPubkey: string | undefined,
currentUserAvatarUrl: string | null,
profiles?: UserProfileLookup,
members?: ChannelMember[],
): TimelineMessage[] {
const currentPubkeyLower = currentPubkey?.toLowerCase();
const roleByPubkey = new Map<string, string>();
if (members) {
for (const member of members) {
roleByPubkey.set(member.pubkey.toLowerCase(), member.role);
}
}
const deletedEventIds = new Set<string>();
for (const event of events) {
if (event.kind !== KIND_DELETION) {
@@ -295,6 +302,7 @@ export function formatTimelineMessages(
currentUserAvatarUrl,
profiles,
}),
role: roleByPubkey.get(authorPubkey.toLowerCase()),
time: formatTime(event.created_at),
body: edit ? edit.content : event.content,
parentId: thread.parentId,

View File

@@ -0,0 +1,32 @@
import * as React from "react";
import { toSvg } from "jdenticon";
type BotIdenticonProps = {
/** The string to generate the identicon from (e.g. the bot's display name or pubkey) */
value: string;
/** Size in pixels (default 20) */
size?: number;
className?: string;
};
/**
* Renders a deterministic geometric identicon for a bot instance.
* Used to visually distinguish numbered bot copies (e.g. Scout::01 vs Scout::02).
*/
export const BotIdenticon = React.memo(function BotIdenticon({
value,
size = 20,
className,
}: BotIdenticonProps) {
const svgHtml = React.useMemo(() => toSvg(value, size), [value, size]);
return (
<div
aria-hidden
className={className}
// biome-ignore lint/security/noDangerouslySetInnerHtml: jdenticon produces safe SVG
dangerouslySetInnerHTML={{ __html: svgHtml }}
style={{ width: size, height: size, flexShrink: 0 }}
/>
);
});

View File

@@ -10,9 +10,15 @@ import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import { Markdown } from "@/shared/ui/markdown";
import { BotIdenticon } from "./BotIdenticon";
import { MessageActionBar } from "./MessageActionBar";
import { MessageTimestamp } from "./MessageTimestamp";
/** Returns true if the author name looks like a numbered bot copy (e.g. "Scout::01") */
function isNumberedBot(author: string, role?: string): boolean {
return Boolean(role) && author.includes("::");
}
const DiffMessage = React.lazy(() => import("./DiffMessage"));
const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
@@ -171,62 +177,71 @@ export const MessageRow = React.memo(
data-message-id={message.id}
data-testid="message-row"
>
{message.pubkey ? (
<UserProfilePopover pubkey={message.pubkey}>
<button
className="shrink-0 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
{message.avatarUrl && !hasAvatarError ? (
<img
alt={`${message.author} avatar`}
className="h-8 w-8 rounded-lg bg-secondary object-cover shadow-sm"
data-testid="message-avatar-image"
onError={() => {
setHasAvatarError(true);
}}
referrerPolicy="no-referrer"
src={rewriteRelayUrl(message.avatarUrl)}
/>
) : (
<div
className={cn(
"flex h-8 w-8 items-center justify-center rounded-lg text-[11px] font-semibold shadow-sm",
message.accent
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground",
)}
data-testid="message-avatar-fallback"
>
{initials}
</div>
<div className="flex shrink-0 items-center gap-1">
{isNumberedBot(message.author, message.role) ? (
<BotIdenticon
value={message.author}
size={20}
className="rounded"
/>
) : null}
{message.pubkey ? (
<UserProfilePopover pubkey={message.pubkey}>
<button
className="shrink-0 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
{message.avatarUrl && !hasAvatarError ? (
<img
alt={`${message.author} avatar`}
className="h-8 w-8 rounded-lg bg-secondary object-cover shadow-sm"
data-testid="message-avatar-image"
onError={() => {
setHasAvatarError(true);
}}
referrerPolicy="no-referrer"
src={rewriteRelayUrl(message.avatarUrl)}
/>
) : (
<div
className={cn(
"flex h-8 w-8 items-center justify-center rounded-lg text-[11px] font-semibold shadow-sm",
message.accent
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground",
)}
data-testid="message-avatar-fallback"
>
{initials}
</div>
)}
</button>
</UserProfilePopover>
) : message.avatarUrl && !hasAvatarError ? (
<img
alt={`${message.author} avatar`}
className="h-8 w-8 shrink-0 rounded-lg bg-secondary object-cover shadow-sm"
data-testid="message-avatar-image"
onError={() => {
setHasAvatarError(true);
}}
referrerPolicy="no-referrer"
src={rewriteRelayUrl(message.avatarUrl)}
/>
) : (
<div
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-[11px] font-semibold shadow-sm",
message.accent
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground",
)}
</button>
</UserProfilePopover>
) : message.avatarUrl && !hasAvatarError ? (
<img
alt={`${message.author} avatar`}
className="h-8 w-8 shrink-0 rounded-lg bg-secondary object-cover shadow-sm"
data-testid="message-avatar-image"
onError={() => {
setHasAvatarError(true);
}}
referrerPolicy="no-referrer"
src={rewriteRelayUrl(message.avatarUrl)}
/>
) : (
<div
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-[11px] font-semibold shadow-sm",
message.accent
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground",
)}
data-testid="message-avatar-fallback"
>
{initials}
</div>
)}
data-testid="message-avatar-fallback"
>
{initials}
</div>
)}
</div>
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">