Fun bot names from persona name pools (#273)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nahiyan Khan
2026-04-08 16:41:08 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 063ce331dd
commit 68a3d16d9e
23 changed files with 388 additions and 57 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ const rules = [
// Exceptions should stay rare and temporary. Prefer splitting files instead.
const overrides = new Map([
["src-tauri/src/managed_agents/personas.rs", 600], // built-in persona system prompts (Solo, Ralph, Strategist) are long string literals
["src-tauri/src/managed_agents/persona_card.rs", 772], // PNG/ZIP persona card codec + provider/model fields + 27 unit tests (~350 lines of tests); rustfmt adds line breaks around long literals/builders
["src-tauri/src/managed_agents/persona_card.rs", 800], // PNG/ZIP persona card codec + provider/model/namePool fields + 27 unit tests (~350 lines of tests); rustfmt adds line breaks around long literals/builders
["src/app/AppShell.tsx", 860], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + memory-leak safeguards
["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
@@ -117,6 +117,12 @@ pub fn update_managed_agent(
let record = find_managed_agent_mut(&mut records, &input.pubkey)?;
if let Some(name_update) = input.name {
let trimmed = name_update.trim().to_string();
if !trimmed.is_empty() {
record.name = trimmed;
}
}
// Tri-state: None = don't touch, Some(None) = clear, Some(Some(v)) = set
if let Some(model_update) = input.model {
record.model = model_update;
+16 -1
View File
@@ -57,6 +57,12 @@ pub fn create_persona(
.lock()
.map_err(|error| error.to_string())?;
let mut personas = load_personas(&app)?;
let name_pool: Vec<String> = input
.name_pool
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let persona = PersonaRecord {
id: Uuid::new_v4().to_string(),
display_name,
@@ -64,6 +70,7 @@ pub fn create_persona(
system_prompt,
provider,
model,
name_pool,
is_builtin: false,
created_at: now.clone(),
updated_at: now,
@@ -104,6 +111,12 @@ pub fn update_persona(
persona.system_prompt = system_prompt;
persona.provider = provider;
persona.model = model;
persona.name_pool = input
.name_pool
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
persona.updated_at = now_iso();
save_personas(&app, &personas)?;
@@ -226,7 +239,7 @@ pub async fn export_persona_to_json(
state: State<'_, AppState>,
) -> Result<bool, String> {
// Load persona data under lock, then drop lock before dialog.
let (display_name, system_prompt, avatar_url, provider, model) = {
let (display_name, system_prompt, avatar_url, provider, model, name_pool) = {
let _store_guard = state
.managed_agents_store_lock
.lock()
@@ -242,6 +255,7 @@ pub async fn export_persona_to_json(
persona.avatar_url.clone(),
persona.provider.clone(),
persona.model.clone(),
persona.name_pool.clone(),
)
};
@@ -251,6 +265,7 @@ pub async fn export_persona_to_json(
avatar_url.as_deref(),
provider.as_deref(),
model.as_deref(),
&name_pool,
)?;
let slug = crate::util::slugify(&display_name, "persona", 50);
@@ -15,6 +15,8 @@ pub struct ParsedPersonaPreview {
pub avatar_data_url: Option<String>,
pub provider: Option<String>,
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub name_pool: Vec<String>,
pub source_file: String,
}
@@ -80,6 +82,7 @@ pub fn parse_png_persona(png_bytes: &[u8]) -> Result<ParsedPersonaPreview, Strin
avatar_data_url,
provider: fields.provider,
model: fields.model,
name_pool: fields.name_pool,
source_file: String::new(),
})
}
@@ -98,6 +101,7 @@ struct SproutPersonaFields {
avatar_url: Option<String>,
provider: Option<String>,
model: Option<String>,
name_pool: Vec<String>,
}
/// Extract and validate fields from a Sprout persona JSON value
@@ -143,12 +147,24 @@ fn extract_sprout_fields(v: &Value) -> Result<SproutPersonaFields, String> {
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let name_pool = v
.get("namePool")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| item.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default();
Ok(SproutPersonaFields {
display_name: name,
system_prompt: prompt,
avatar_url,
provider,
model,
name_pool,
})
}
@@ -192,6 +208,7 @@ fn parse_chara_payload(b64: &str) -> Result<SproutPersonaFields, String> {
avatar_url: None,
provider: None,
model: None,
name_pool: Vec::new(),
})
}
@@ -209,6 +226,7 @@ pub fn parse_json_persona(json_bytes: &[u8]) -> Result<ParsedPersonaPreview, Str
avatar_data_url: fields.avatar_url,
provider: fields.provider,
model: fields.model,
name_pool: fields.name_pool,
source_file: String::new(),
})
}
@@ -219,6 +237,7 @@ pub fn encode_persona_json(
avatar_url: Option<&str>,
provider: Option<&str>,
model: Option<&str>,
name_pool: &[String],
) -> Result<Vec<u8>, String> {
let mut map = serde_json::Map::new();
map.insert("version".to_string(), serde_json::json!(1));
@@ -233,6 +252,9 @@ pub fn encode_persona_json(
if let Some(m) = model {
map.insert("model".to_string(), serde_json::json!(m));
}
if !name_pool.is_empty() {
map.insert("namePool".to_string(), serde_json::json!(name_pool));
}
serde_json::to_vec_pretty(&map).map_err(|e| format!("Failed to serialize JSON: {e}"))
}
@@ -614,6 +636,7 @@ mod tests {
Some("https://example.com/ada.png"),
None,
None,
&[],
)
.unwrap();
let result = parse_json_persona(&bytes).unwrap();
@@ -628,7 +651,7 @@ mod tests {
#[test]
fn parse_json_round_trip_no_avatar() {
let bytes = encode_persona_json("Bob", "You are Bob.", None, None, None).unwrap();
let bytes = encode_persona_json("Bob", "You are Bob.", None, None, None, &[]).unwrap();
let result = parse_json_persona(&bytes).unwrap();
assert_eq!(result.display_name, "Bob");
assert_eq!(result.system_prompt, "You are Bob.");
@@ -638,8 +661,8 @@ mod tests {
#[test]
fn parse_json_round_trip_data_uri_avatar() {
let data_uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==";
let bytes =
encode_persona_json("Carol", "You are Carol.", Some(data_uri), None, None).unwrap();
let bytes = encode_persona_json("Carol", "You are Carol.", Some(data_uri), None, None, &[])
.unwrap();
let result = parse_json_persona(&bytes).unwrap();
assert_eq!(result.display_name, "Carol");
assert_eq!(result.avatar_data_url.as_deref(), Some(data_uri));
@@ -653,6 +676,7 @@ mod tests {
None,
Some("goose"),
Some("claude-sonnet-4"),
&[],
)
.unwrap();
let result = parse_json_persona(&bytes).unwrap();
@@ -665,7 +689,7 @@ mod tests {
#[test]
fn parse_json_round_trip_without_provider_and_model() {
let bytes = encode_persona_json("Bob", "You are Bob.", None, None, None).unwrap();
let bytes = encode_persona_json("Bob", "You are Bob.", None, None, None, &[]).unwrap();
let result = parse_json_persona(&bytes).unwrap();
assert_eq!(result.display_name, "Bob");
assert!(result.provider.is_none());
@@ -727,8 +751,8 @@ mod tests {
#[test]
fn parse_zip_with_json() {
let j1 = encode_persona_json("Alice", "Prompt A", None, None, None).unwrap();
let j2 = encode_persona_json("Bob", "Prompt B", None, None, None).unwrap();
let j1 = encode_persona_json("Alice", "Prompt A", None, None, None, &[]).unwrap();
let j2 = encode_persona_json("Bob", "Prompt B", None, None, None, &[]).unwrap();
let zip = make_test_zip(&[("alice.persona.json", &j1), ("bob.persona.json", &j2)]);
let result = parse_zip_personas(&zip).unwrap();
assert_eq!(result.personas.len(), 2);
@@ -740,7 +764,8 @@ mod tests {
#[test]
fn parse_zip_mixed_png_and_json() {
let png = make_test_persona_png("PngPersona", "PNG prompt");
let json = encode_persona_json("JsonPersona", "JSON prompt", None, None, None).unwrap();
let json =
encode_persona_json("JsonPersona", "JSON prompt", None, None, None, &[]).unwrap();
let zip = make_test_zip(&[
("persona.png", &png),
("persona.json", &json),
@@ -755,8 +780,8 @@ mod tests {
#[test]
fn parse_zip_ignores_macos_resource_forks() {
let j1 = encode_persona_json("Frank", "You are Frank.", None, None, None).unwrap();
let j2 = encode_persona_json("Jackie", "You are Jackie.", None, None, None).unwrap();
let j1 = encode_persona_json("Frank", "You are Frank.", None, None, None, &[]).unwrap();
let j2 = encode_persona_json("Jackie", "You are Jackie.", None, None, None, &[]).unwrap();
let zip = make_test_zip(&[
("frank-costanza.persona.json", &j1),
("jackie-chiles.persona.json", &j2),
File diff suppressed because one or more lines are too long
@@ -217,6 +217,7 @@ mod tests {
system_prompt: prompt.to_string(),
provider: None,
model: None,
name_pool: Vec::new(),
is_builtin: false,
created_at: "2026-03-20T00:00:00Z".to_string(),
updated_at: "2026-03-20T00:00:00Z".to_string(),
@@ -27,6 +27,10 @@ pub struct PersonaRecord {
/// Passed to the agent at creation time when deploying from this persona.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Pool of short, thematic names for bot instances created from this persona.
/// When a new copy is added to a channel, a random unused name is picked from this pool.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub name_pool: Vec<String>,
#[serde(default)]
pub is_builtin: bool,
pub created_at: String,
@@ -177,6 +181,8 @@ pub struct CreatePersonaRequest {
pub provider: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub name_pool: Vec<String>,
}
#[derive(Debug, Deserialize)]
@@ -190,6 +196,8 @@ pub struct UpdatePersonaRequest {
pub provider: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub name_pool: Vec<String>,
}
#[derive(Debug, Deserialize)]
@@ -253,6 +261,9 @@ pub struct ManagedAgentPrereqsInfo {
#[serde(rename_all = "camelCase")]
pub struct UpdateManagedAgentRequest {
pub pubkey: String,
/// Absent = don't touch. Present = rename the agent.
#[serde(default)]
pub name: Option<String>,
/// Absent = don't touch. null = clear to agent default. "id" = set.
#[serde(default)]
pub model: Option<Option<String>>,
@@ -0,0 +1,79 @@
/**
* Universal fallback pool of short, distinctive names used when a persona's
* own name pool is empty or exhausted.
*/
const UNIVERSAL_POOL = [
"Alder",
"Brook",
"Coral",
"Dawn",
"Echo",
"Frost",
"Gale",
"Heath",
"Ivy",
"Jade",
"Kite",
"Luna",
"Maple",
"Nova",
"Opal",
"Pyre",
"Quartz",
"Rune",
"Silk",
"Thorn",
"Umber",
"Vale",
"Wisp",
"Yarn",
"Zinc",
"Brine",
"Cove",
"Drift",
"Elm",
"Fjord",
];
/**
* Pick a random unused name for a bot instance.
*
* 1. Try the persona's own name pool first.
* 2. If exhausted, fall back to the universal pool.
* 3. If both are exhausted, append a 2-digit suffix to a random name.
*/
export function pickBotName(
namePool: string[],
usedNames: Set<string>,
): string {
const usedLower = new Set([...usedNames].map((n) => n.toLowerCase()));
const pick = (pool: readonly string[]) => {
const available = pool.filter((n) => !usedLower.has(n.toLowerCase()));
if (available.length === 0) return null;
return available[Math.floor(Math.random() * available.length)];
};
// Try persona pool first
if (namePool.length > 0) {
const name = pick(namePool);
if (name) return name;
}
// Fallback to universal pool
const fallback = pick(UNIVERSAL_POOL);
if (fallback) return fallback;
// Both pools exhausted — pick a random base name and add a suffix
const allNames = namePool.length > 0 ? namePool : UNIVERSAL_POOL;
const base = allNames[Math.floor(Math.random() * allNames.length)];
for (let i = 2; i < 100; i++) {
const suffixed = `${base}-${String(i).padStart(2, "0")}`;
if (!usedLower.has(suffixed.toLowerCase())) {
return suffixed;
}
}
// Extremely unlikely fallback
return `${base}-${Date.now() % 1000}`;
}
@@ -48,6 +48,7 @@ export function PersonaDialog({
const [systemPrompt, setSystemPrompt] = React.useState("");
const [provider, setProvider] = React.useState("");
const [model, setModel] = React.useState("");
const [namePoolText, setNamePoolText] = React.useState("");
React.useEffect(() => {
if (!open || !initialValues) {
@@ -59,6 +60,12 @@ export function PersonaDialog({
setSystemPrompt(initialValues.systemPrompt);
setProvider(initialValues.provider ?? "");
setModel(initialValues.model ?? "");
setNamePoolText(
("namePool" in initialValues
? (initialValues as { namePool?: string[] }).namePool
: undefined
)?.join(", ") ?? "",
);
}, [initialValues, open]);
function handleOpenChange(next: boolean) {
@@ -68,6 +75,7 @@ export function PersonaDialog({
setSystemPrompt("");
setProvider("");
setModel("");
setNamePoolText("");
}
onOpenChange(next);
@@ -78,12 +86,17 @@ export function PersonaDialog({
return;
}
const namePool = namePoolText
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const baseInput = {
displayName,
avatarUrl: avatarUrl.trim() || undefined,
systemPrompt,
provider: provider.trim() || undefined,
model: model.trim() || undefined,
namePool: namePool.length > 0 ? namePool : undefined,
};
if ("id" in initialValues) {
@@ -212,6 +225,29 @@ export function PersonaDialog({
</p>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="persona-name-pool"
>
Instance name pool
</label>
<Input
autoCapitalize="none"
autoCorrect="off"
disabled={isPending}
id="persona-name-pool"
onChange={(event) => setNamePoolText(event.target.value)}
placeholder="Birch, Compass, Ridge, Thistle, ..."
spellCheck={false}
value={namePoolText}
/>
<p className="text-xs text-muted-foreground">
Comma-separated names for bot copies. Each instance gets a
random name from this pool. Leave empty to use generic defaults.
</p>
</div>
{error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error.message}
@@ -8,6 +8,7 @@ import {
usePersonasQuery,
useRelayAgentsQuery,
} from "@/features/agents/hooks";
import { pickBotName } from "@/features/agents/lib/pickBotName";
import {
useBotRecents,
DEFAULT_PERSONA_NAMES,
@@ -68,9 +69,9 @@ export function ChannelMembersBar({
const { recentIds, pushRecent } = useBotRecents();
const quickDrop = useQuickBotDrop(channel.id);
// Track in-flight instance numbers so rapid clicks don't produce duplicates.
// Track in-flight instance names so rapid clicks don't produce duplicates.
// Cleared when the members query refetches with the new member.
const inflightCountRef = React.useRef<Record<string, number>>({});
const inflightNamesRef = React.useRef<Record<string, string[]>>({});
// Resolve the 3 personas to show in the quick bar.
// Use recents if available, otherwise fall back to default names.
@@ -99,35 +100,34 @@ export function ChannelMembersBar({
}
}
// Reset in-flight counts when members list updates (the new bot appeared).
inflightCountRef.current = {};
// Reset in-flight names when members list updates (the new bot appeared).
inflightNamesRef.current = {};
// Compute instance names from current members
// Build the set of names already used in this channel
const usedNames = new Set(
members.map((m) => m.displayName ?? "").filter((n) => n.length > 0),
);
// Compute instance names from persona name pools
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")}`,
};
// Include in-flight names to avoid duplicates on rapid clicks
const inflight = inflightNamesRef.current[persona.id] ?? [];
const combinedUsed = new Set(usedNames);
for (const n of inflight) combinedUsed.add(n);
const instanceName = pickBotName(persona.namePool ?? [], combinedUsed);
return { persona, instanceName };
});
}, [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;
// Optimistically track the chosen name to avoid duplicates on rapid clicks.
inflightNamesRef.current[persona.id] = [
...(inflightNamesRef.current[persona.id] ?? []),
instanceName,
];
pushRecent(persona.id);
await addBot(persona, instanceName);
},
@@ -38,6 +38,8 @@ type ChannelPaneProps = {
emoji: string,
remove: boolean,
) => Promise<void>;
/** Map from lowercase pubkey → persona display name for bot members. */
personaLookup?: Map<string, string>;
profiles?: UserProfileLookup;
replyTargetId: string | null;
replyTargetMessage: TimelineMessage | null;
@@ -64,6 +66,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onSend,
onTargetReached,
onToggleReaction,
personaLookup,
profiles,
replyTargetId,
replyTargetMessage,
@@ -79,6 +82,7 @@ export const ChannelPane = React.memo(function ChannelPane({
fetchOlder={fetchOlder}
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
personaLookup={personaLookup}
profiles={profiles}
emptyDescription={
activeChannel?.channelType === "forum"
@@ -8,6 +8,10 @@ import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandle
import { useChannelMembersQuery } from "@/features/channels/hooks";
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
import { MembersSidebar } from "@/features/channels/ui/MembersSidebar";
import {
useManagedAgentsQuery,
usePersonasQuery,
} from "@/features/agents/hooks";
import {
mergeMessages,
useChannelMessagesQuery,
@@ -140,16 +144,52 @@ export function ChannelScreen({
const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, {
enabled: messageProfilePubkeys.length > 0,
});
const messageProfiles = React.useMemo(
() =>
const managedAgentsQuery = useManagedAgentsQuery();
const messageProfiles = React.useMemo(() => {
const base =
mergeCurrentProfileIntoLookup(
messageProfilesQuery.data?.profiles,
currentProfile,
),
[currentProfile, messageProfilesQuery.data?.profiles],
);
) ?? {};
// Merge managed agent names so system messages resolve instantly
// (without waiting for the relay profile batch query).
const agents = managedAgentsQuery.data ?? [];
const merged = { ...base };
for (const agent of agents) {
const key = agent.pubkey.toLowerCase();
if (!merged[key]?.displayName) {
merged[key] = {
...merged[key],
displayName: agent.name,
avatarUrl: null,
nip05Handle: null,
};
}
}
return merged;
}, [
currentProfile,
managedAgentsQuery.data,
messageProfilesQuery.data?.profiles,
]);
const channelMembersQuery = useChannelMembersQuery(activeChannel?.id ?? null);
const channelMembers = channelMembersQuery.data;
const personasQuery = usePersonasQuery();
const personaLookup = React.useMemo(() => {
const agents = managedAgentsQuery.data ?? [];
const personas = personasQuery.data ?? [];
const personaById = new Map(personas.map((p) => [p.id, p.displayName]));
const lookup = new Map<string, string>();
for (const agent of agents) {
if (agent.personaId) {
const personaName = personaById.get(agent.personaId);
if (personaName) {
lookup.set(agent.pubkey.toLowerCase(), personaName);
}
}
}
return lookup;
}, [managedAgentsQuery.data, personasQuery.data]);
const timelineMessages = React.useMemo(
() =>
formatTimelineMessages(
@@ -159,6 +199,7 @@ export function ChannelScreen({
currentProfile?.avatarUrl ?? null,
messageProfiles,
channelMembers,
personaLookup,
),
[
activeChannel,
@@ -166,6 +207,7 @@ export function ChannelScreen({
currentProfile?.avatarUrl,
currentPubkey,
messageProfiles,
personaLookup,
resolvedMessages,
],
);
@@ -403,6 +445,7 @@ export function ChannelScreen({
onReply={handleReply}
onSend={handleSend}
onToggleReaction={effectiveToggleReaction}
personaLookup={personaLookup}
profiles={messageProfiles}
replyTargetId={replyTargetId}
replyTargetMessage={replyTargetMessage}
@@ -87,7 +87,7 @@ export function QuickBotBar({ personas, pending, onAdd }: QuickBotBarProps) {
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Add {persona.displayName} {instanceName}
Add {instanceName} ({persona.displayName})
</TooltipContent>
</Tooltip>
);
@@ -114,6 +114,8 @@ export function formatTimelineMessages(
currentUserAvatarUrl: string | null,
profiles?: UserProfileLookup,
members?: ChannelMember[],
/** Map from lowercase pubkey → persona display name for bot messages. */
personaLookup?: Map<string, string>,
): TimelineMessage[] {
const currentPubkeyLower = currentPubkey?.toLowerCase();
const roleByPubkey = new Map<string, string>();
@@ -291,6 +293,7 @@ export function formatTimelineMessages(
});
const thread = getThreadReference(event.tags);
const edit = editsByTargetId.get(event.id);
const role = roleByPubkey.get(authorPubkey.toLowerCase());
return {
id: event.id,
createdAt: event.created_at,
@@ -302,7 +305,11 @@ export function formatTimelineMessages(
currentUserAvatarUrl,
profiles,
}),
role: roleByPubkey.get(authorPubkey.toLowerCase()),
role,
personaDisplayName:
role === "bot"
? personaLookup?.get(authorPubkey.toLowerCase())
: undefined,
time: formatTime(event.created_at),
body: edit ? edit.content : event.content,
parentId: thread.parentId,
@@ -1,6 +1,9 @@
import * as React from "react";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
import {
useManagedAgentsQuery,
usePersonasQuery,
} from "@/features/agents/hooks";
import { useChannelMembersQuery } from "@/features/channels/hooks";
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
@@ -17,6 +20,7 @@ export function useMentions(channelId: string | null) {
const membersQuery = useChannelMembersQuery(channelId);
const members = membersQuery.data;
const managedAgentsQuery = useManagedAgentsQuery();
const personasQuery = usePersonasQuery();
const managedAgentNamesByPubkey = React.useMemo(
() =>
new Map(
@@ -27,20 +31,41 @@ export function useMentions(channelId: string | null) {
),
[managedAgentsQuery.data],
);
const personaNameByPubkey = React.useMemo(() => {
const agents = managedAgentsQuery.data ?? [];
const personas = personasQuery.data ?? [];
const personaById = new Map(personas.map((p) => [p.id, p.displayName]));
const lookup = new Map<string, string>();
for (const agent of agents) {
if (agent.personaId) {
const name = personaById.get(agent.personaId);
if (name) lookup.set(agent.pubkey.toLowerCase(), name);
}
}
return lookup;
}, [managedAgentsQuery.data, personasQuery.data]);
const knownNames = React.useMemo<string[]>(() => {
if (!members) return [];
const names: string[] = [];
const seen = new Set<string>();
for (const member of members) {
const name =
member.displayName ??
managedAgentNamesByPubkey.get(member.pubkey.toLowerCase());
if (name) {
names.push(name);
seen.add(name.toLowerCase());
}
// Also include persona names so typing @Scout triggers the dropdown
const personaName = personaNameByPubkey.get(member.pubkey.toLowerCase());
if (personaName && !seen.has(personaName.toLowerCase())) {
names.push(personaName);
seen.add(personaName.toLowerCase());
}
}
return names;
}, [members, managedAgentNamesByPubkey]);
}, [members, managedAgentNamesByPubkey, personaNameByPubkey]);
/** Lower-cased version of knownNames, used for case-insensitive prefix matching. */
const knownNamesLower = React.useMemo<string[]>(
@@ -78,27 +103,31 @@ export function useMentions(channelId: string | null) {
const lowerQuery = mentionQuery.toLowerCase();
return (members ?? [])
.map((member) => {
const pubkeyLower = member.pubkey.toLowerCase();
const fallbackName =
managedAgentNamesByPubkey.get(member.pubkey.toLowerCase()) ??
managedAgentNamesByPubkey.get(pubkeyLower) ??
member.pubkey.slice(0, 8);
return {
member,
label: member.displayName ?? fallbackName,
personaName: personaNameByPubkey.get(pubkeyLower) ?? null,
};
})
.filter(
({ label, member }) =>
({ label, member, personaName }) =>
label.toLowerCase().includes(lowerQuery) ||
member.pubkey.toLowerCase().includes(lowerQuery),
member.pubkey.toLowerCase().includes(lowerQuery) ||
personaName?.toLowerCase().includes(lowerQuery),
)
.slice(0, 8)
.map(({ member, label }) => ({
.map(({ member, label, personaName }) => ({
pubkey: member.pubkey,
displayName: label,
role: member.role === "admin" ? "admin" : null,
personaName,
}));
}, [managedAgentNamesByPubkey, members, mentionQuery]);
}, [managedAgentNamesByPubkey, members, mentionQuery, personaNameByPubkey]);
const isMentionOpen = mentionQuery !== null && suggestions.length > 0;
+2
View File
@@ -12,6 +12,8 @@ export type TimelineMessage = {
author: string;
avatarUrl?: string | null;
role?: string;
/** For bot messages, the display name of the persona this bot was created from. */
personaDisplayName?: string;
time: string;
body: string;
parentId?: string | null;
@@ -6,6 +6,7 @@ export type MentionSuggestion = {
pubkey: string;
displayName: string;
role?: string | null;
personaName?: string | null;
};
type MentionAutocompleteProps = {
@@ -57,7 +58,11 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
<span className="truncate font-medium">
{suggestion.displayName}
</span>
{suggestion.role ? (
{suggestion.personaName ? (
<span className="text-xs text-muted-foreground">
({suggestion.personaName})
</span>
) : suggestion.role ? (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{suggestion.role}
</span>
@@ -14,9 +14,9 @@ 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("::");
/** Returns true if this message is from a bot instance. */
function isBotInstance(role?: string): boolean {
return role === "bot";
}
const DiffMessage = React.lazy(() => import("./DiffMessage"));
@@ -178,7 +178,7 @@ export const MessageRow = React.memo(
data-testid="message-row"
>
<div className="flex shrink-0 items-center gap-1">
{isNumberedBot(message.author, message.role) ? (
{isBotInstance(message.role) ? (
<BotIdenticon
value={message.author}
size={20}
@@ -259,7 +259,11 @@ export const MessageRow = React.memo(
{message.author}
</h3>
)}
{message.role ? (
{message.personaDisplayName ? (
<span className="text-xs text-muted-foreground">
{message.personaDisplayName}
</span>
) : message.role ? (
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{message.role}
</p>
@@ -353,6 +357,7 @@ export const MessageRow = React.memo(
prev.message.reactions === next.message.reactions &&
prev.message.tags === next.message.tags &&
prev.message.role === next.message.role &&
prev.message.personaDisplayName === next.message.personaDisplayName &&
prev.highlighted === next.highlighted &&
prev.activeReplyTargetId === next.activeReplyTargetId &&
prev.profiles === next.profiles,
@@ -23,6 +23,8 @@ type MessageTimelineProps = {
fetchOlder?: () => Promise<void>;
hasOlderMessages?: boolean;
isFetchingOlder?: boolean;
/** Map from lowercase pubkey → persona display name for bot members. */
personaLookup?: Map<string, string>;
profiles?: UserProfileLookup;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
@@ -47,6 +49,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
fetchOlder,
hasOlderMessages = true,
isFetchingOlder = false,
personaLookup,
profiles,
onDelete,
onEdit,
@@ -158,6 +161,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
onEdit={onEdit}
onReply={onReply}
onToggleReaction={onToggleReaction}
personaLookup={personaLookup}
profiles={profiles}
/>
) : null}
@@ -23,20 +23,31 @@ function resolveLabel(
return resolveUserLabel({ pubkey, currentPubkey, profiles });
}
function resolvePersonaSuffix(
pubkey: string | undefined,
personaLookup: Map<string, string> | undefined,
): string {
if (!pubkey || !personaLookup) return "";
const personaName = personaLookup.get(pubkey.toLowerCase());
return personaName ? ` (${personaName})` : "";
}
function describeSystemEvent(
payload: SystemMessagePayload,
currentPubkey: string | undefined,
profiles: UserProfileLookup | undefined,
personaLookup?: Map<string, string>,
): string | null {
const actor = resolveLabel(payload.actor, currentPubkey, profiles);
switch (payload.type) {
case "member_joined": {
const target = resolveLabel(payload.target, currentPubkey, profiles);
const personaSuffix = resolvePersonaSuffix(payload.target, personaLookup);
if (payload.actor === payload.target) {
return `${actor} joined the channel`;
return `${actor}${personaSuffix} joined the channel`;
}
return `${actor} added ${target} to the channel`;
return `${actor} added ${target}${personaSuffix} to the channel`;
}
case "member_left": {
return `${actor} left the channel`;
@@ -62,12 +73,15 @@ export function SystemMessageRow({
time,
currentPubkey,
profiles,
personaLookup,
}: {
body: string;
createdAt: number;
time: string;
currentPubkey?: string;
profiles?: UserProfileLookup;
/** Map from lowercase pubkey → persona display name for bot members. */
personaLookup?: Map<string, string>;
}) {
let payload: SystemMessagePayload;
try {
@@ -76,7 +90,12 @@ export function SystemMessageRow({
return null;
}
const description = describeSystemEvent(payload, currentPubkey, profiles);
const description = describeSystemEvent(
payload,
currentPubkey,
profiles,
personaLookup,
);
if (!description) {
return null;
}
@@ -24,6 +24,8 @@ type TimelineMessageListProps = {
emoji: string,
remove: boolean,
) => Promise<void>;
/** Map from lowercase pubkey → persona display name for bot members. */
personaLookup?: Map<string, string>;
profiles?: UserProfileLookup;
};
@@ -36,6 +38,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
onEdit,
onReply,
onToggleReaction,
personaLookup,
profiles,
}: TimelineMessageListProps) {
const elements: React.ReactNode[] = [];
@@ -60,6 +63,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
body={message.body}
createdAt={message.createdAt}
currentPubkey={currentPubkey}
personaLookup={personaLookup}
profiles={profiles}
time={message.time}
/>,
+7
View File
@@ -12,6 +12,7 @@ type RawParsedPersonaPreview = {
avatar_data_url: string | null;
provider: string | null;
model: string | null;
name_pool?: string[];
source_file: string;
};
@@ -32,6 +33,7 @@ export type ParsedPersonaPreview = {
avatarDataUrl: string | null;
provider: string | null;
model: string | null;
namePool: string[];
sourceFile: string;
};
@@ -52,6 +54,7 @@ type RawPersona = {
system_prompt: string;
provider?: string | null;
model?: string | null;
name_pool?: string[];
is_builtin: boolean;
created_at: string;
updated_at: string;
@@ -65,6 +68,7 @@ function fromRawPersona(persona: RawPersona): AgentPersona {
systemPrompt: persona.system_prompt,
provider: persona.provider ?? null,
model: persona.model ?? null,
namePool: persona.name_pool ?? [],
isBuiltIn: persona.is_builtin,
createdAt: persona.created_at,
updatedAt: persona.updated_at,
@@ -86,6 +90,7 @@ export async function createPersona(
systemPrompt: input.systemPrompt,
provider: input.provider,
model: input.model,
namePool: input.namePool ?? [],
},
}),
);
@@ -103,6 +108,7 @@ export async function updatePersona(
systemPrompt: input.systemPrompt,
provider: input.provider,
model: input.model,
namePool: input.namePool ?? [],
},
}),
);
@@ -127,6 +133,7 @@ export async function parsePersonaFiles(
avatarDataUrl: p.avatar_data_url,
provider: p.provider,
model: p.model,
namePool: p.name_pool ?? [],
sourceFile: p.source_file,
})),
skipped: raw.skipped.map((s) => ({
+6
View File
@@ -404,6 +404,7 @@ export type AgentModelInfo = {
};
export type UpdateManagedAgentInput = {
pubkey: string;
name?: string;
model?: string | null;
systemPrompt?: string | null;
};
@@ -416,6 +417,7 @@ export type AgentPersona = {
provider: string | null;
/** Preferred model ID (e.g. "gpt-4o", "claude-sonnet-4-20250514"). */
model: string | null;
namePool: string[];
isBuiltIn: boolean;
createdAt: string;
updatedAt: string;
@@ -427,6 +429,7 @@ export type CreatePersonaInput = {
systemPrompt: string;
provider?: string;
model?: string;
namePool?: string[];
};
export type UpdatePersonaInput = {
@@ -436,7 +439,10 @@ export type UpdatePersonaInput = {
systemPrompt: string;
provider?: string;
model?: string;
namePool?: string[];
};
// ── Team types ────────────────────────────────────────────────────────────────
export type AgentTeam = {
id: string;
name: string;