diff --git a/desktop/package.json b/desktop/package.json index 1e9967d6be..bd1e9d2030 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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", diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 81915ee5e4..287c61e414 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -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 diff --git a/desktop/src/features/agents/lib/useBotRecents.ts b/desktop/src/features/agents/lib/useBotRecents.ts new file mode 100644 index 0000000000..31b0322d16 --- /dev/null +++ b/desktop/src/features/agents/lib/useBotRecents.ts @@ -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(() => { + 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 }; +} diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index f14b1722ce..940ea50581 100644 --- a/desktop/src/features/channels/ui/ChannelMembersBar.tsx +++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx @@ -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>({}); + + // 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 (
- +
+ {canAddAgents ? ( + + ) : null} + +
+ + + Add {persona.displayName} → {instanceName} + + + ); + })} +
+ + ); +} diff --git a/desktop/src/features/channels/ui/useQuickBotDrop.ts b/desktop/src/features/channels/ui/useQuickBotDrop.ts new file mode 100644 index 0000000000..34b83c5e1d --- /dev/null +++ b/desktop/src/features/channels/ui/useQuickBotDrop.ts @@ -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({ + 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 }; +} diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index 723edc7d0f..83360bd973 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -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(); + if (members) { + for (const member of members) { + roleByPubkey.set(member.pubkey.toLowerCase(), member.role); + } + } const deletedEventIds = new Set(); 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, diff --git a/desktop/src/features/messages/ui/BotIdenticon.tsx b/desktop/src/features/messages/ui/BotIdenticon.tsx new file mode 100644 index 0000000000..90309cccb0 --- /dev/null +++ b/desktop/src/features/messages/ui/BotIdenticon.tsx @@ -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 ( +
+ ); +}); diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 01f1110f9f..6908865745 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -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 ? ( - - + + ) : message.avatarUrl && !hasAvatarError ? ( + {`${message.author} { + setHasAvatarError(true); + }} + referrerPolicy="no-referrer" + src={rewriteRelayUrl(message.avatarUrl)} + /> + ) : ( +
- - ) : message.avatarUrl && !hasAvatarError ? ( - {`${message.author} { - setHasAvatarError(true); - }} - referrerPolicy="no-referrer" - src={rewriteRelayUrl(message.avatarUrl)} - /> - ) : ( -
- {initials} -
- )} + data-testid="message-avatar-fallback" + > + {initials} +
+ )} +