mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat: inline quick-add bot bar + identicons for numbered bot copies (#255)
This commit is contained in:
@@ -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
53
desktop/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
39
desktop/src/features/agents/lib/useBotRecents.ts
Normal file
39
desktop/src/features/agents/lib/useBotRecents.ts
Normal 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 };
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
98
desktop/src/features/channels/ui/QuickBotBar.tsx
Normal file
98
desktop/src/features/channels/ui/QuickBotBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
desktop/src/features/channels/ui/useQuickBotDrop.ts
Normal file
71
desktop/src/features/channels/ui/useQuickBotDrop.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
32
desktop/src/features/messages/ui/BotIdenticon.tsx
Normal file
32
desktop/src/features/messages/ui/BotIdenticon.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user