mirror of
https://github.com/block/buzz.git
synced 2026-11-12 12:22:06 +00:00
Agents are now only ever minted explicitly through the Create Agent
wizard. Personas surface exclusively inside the New Agent catalog as
"saved templates" instead of rendering as cards on the Agents grid.
Remove the flatten/materialization migration:
- Delete managed_agents/flatten.rs and flatten_tests.rs — the boot-time
migration minted a fresh keypair for every active persona with no
linked agent, but keyed dedup on the persona_id link it erased itself,
so relay persona echoes from other builds (dev vs release, d-tag
drift) re-minted duplicate agents on every launch on both sides
- Remove the flatten_personas_into_agents boot hookup in lib.rs
- Remove materialize_pack_agents_locked from team pack install/sync in
commands/teams.rs — installing a pack now lands its personas in the
catalog without minting agents
Catalog = built-ins + saved templates:
- list_agent_templates merges static built-ins with active persona
records (store lock held, spawn_blocking), skipping records that
shadow a built-in id and sorting saved entries by display name
- AgentTemplate gains provider and source ("builtin" | "saved") on both
the Rust struct and the TS type; templateAgentDraft carries provider
through to the create form prefill
- CreateAgentStartDialog renders saved templates under a "Saved"
sidebar group; detail pane labels the type accordingly
"Save as template" on minted agents:
- New save_agent_as_template Tauri command maps an agent's pinned
config (name, instructions, avatar, runtime, model, provider — never
env_vars) onto a persona record; re-saving the same name updates the
existing template instead of duplicating; retained as kind:30175
pending relay sync so templates reach other devices
- AgentActionsMenu gains a "Save as template" entry wired through
UnifiedAgentsSection/AgentsView with success toast and
agent-templates/personas query invalidation
Tests:
- e2eBridge mocks the merged template list and save_agent_as_template
- agents.spec.ts covers the new menu entry and the save-as-template →
Saved group → detail pane flow
- Stale flatten references in persona/teams test comments cleaned up
873 Tauri unit tests, 69 Playwright tests, and the full just ci gate
pass.
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
514 lines
16 KiB
TypeScript
514 lines
16 KiB
TypeScript
import * as React from "react";
|
|
|
|
import {
|
|
type AttachManagedAgentToChannelResult,
|
|
useExportAgentJsonMutation,
|
|
useManagedAgentLogQuery,
|
|
useManagedAgentsQuery,
|
|
useRelayAgentsQuery,
|
|
useSaveAgentAsTemplateMutation,
|
|
useSetManagedAgentStartOnAppLaunchMutation,
|
|
useStartManagedAgentMutation,
|
|
useStopManagedAgentMutation,
|
|
useDeleteManagedAgentMutation,
|
|
} from "@/features/agents/hooks";
|
|
import { useChannelsQuery } from "@/features/channels/hooks";
|
|
import { usePresenceQuery } from "@/features/presence/hooks";
|
|
import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore";
|
|
import { useActiveAgentTurnsBridge } from "@/features/agents/activeAgentTurnsStore";
|
|
import type {
|
|
AgentTemplate,
|
|
Channel,
|
|
CreateManagedAgentResponse,
|
|
ManagedAgent,
|
|
} from "@/shared/api/types";
|
|
import { removeChannelMember } from "@/shared/api/tauri";
|
|
import {
|
|
parsePersonaFiles,
|
|
type ParsePersonaFilesResult,
|
|
} from "@/shared/api/tauriPersonas";
|
|
import { isSingleItemFile } from "@/shared/lib/fileMagic";
|
|
import { normalizePubkey } from "@/shared/lib/pubkey";
|
|
import {
|
|
deleteManagedAgentWithRules,
|
|
isManagedAgentActive,
|
|
startManagedAgentWithRules,
|
|
stopManagedAgentWithRules,
|
|
} from "../lib/managedAgentControlActions";
|
|
import {
|
|
blankAgentDraft,
|
|
duplicateAgentDraft,
|
|
importAgentDraft,
|
|
templateAgentDraft,
|
|
type AgentDraft,
|
|
} from "./agentDraft";
|
|
|
|
export function useManagedAgentActions() {
|
|
const relayAgentsQuery = useRelayAgentsQuery();
|
|
const managedAgentsQuery = useManagedAgentsQuery();
|
|
const [shouldLoadChannels, setShouldLoadChannels] = React.useState(false);
|
|
const channelsQuery = useChannelsQuery({ enabled: shouldLoadChannels });
|
|
const startMutation = useStartManagedAgentMutation();
|
|
const stopMutation = useStopManagedAgentMutation();
|
|
const deleteMutation = useDeleteManagedAgentMutation();
|
|
const startOnLaunchMutation = useSetManagedAgentStartOnAppLaunchMutation();
|
|
const exportAgentJsonMutation = useExportAgentJsonMutation();
|
|
const saveAsTemplateMutation = useSaveAgentAsTemplateMutation();
|
|
const [isCreateStartOpen, setIsCreateStartOpen] = React.useState(false);
|
|
const [createDraft, setCreateDraft] = React.useState<AgentDraft | null>(null);
|
|
const [batchImportResult, setBatchImportResult] =
|
|
React.useState<ParsePersonaFilesResult | null>(null);
|
|
const [batchImportFileName, setBatchImportFileName] = React.useState("");
|
|
const [agentToAddToChannel, setAgentToAddToChannel] =
|
|
React.useState<ManagedAgent | null>(null);
|
|
const [createdAgent, setCreatedAgent] =
|
|
React.useState<CreateManagedAgentResponse | null>(null);
|
|
const [logAgentPubkey, setLogAgentPubkey] = React.useState<string | null>(
|
|
null,
|
|
);
|
|
const [actionNoticeMessage, setActionNoticeMessage] = React.useState<
|
|
string | null
|
|
>(null);
|
|
const [actionErrorMessage, setActionErrorMessage] = React.useState<
|
|
string | null
|
|
>(null);
|
|
|
|
const managedAgentLogQuery = useManagedAgentLogQuery(logAgentPubkey);
|
|
|
|
React.useEffect(() => {
|
|
const timeoutId = window.setTimeout(() => {
|
|
setShouldLoadChannels(true);
|
|
}, 0);
|
|
return () => window.clearTimeout(timeoutId);
|
|
}, []);
|
|
|
|
const managedAgents = React.useMemo(
|
|
() =>
|
|
[...(managedAgentsQuery.data ?? [])].sort((left, right) => {
|
|
const activeScore = (s: string) =>
|
|
s === "running" || s === "deployed" ? 1 : 0;
|
|
const diff = activeScore(right.status) - activeScore(left.status);
|
|
if (diff !== 0) return diff;
|
|
return left.name.localeCompare(right.name);
|
|
}),
|
|
[managedAgentsQuery.data],
|
|
);
|
|
useManagedAgentObserverBridge(managedAgents);
|
|
useActiveAgentTurnsBridge(managedAgents);
|
|
|
|
const managedPubkeys = React.useMemo(
|
|
() => new Set(managedAgents.map((agent) => agent.pubkey)),
|
|
[managedAgents],
|
|
);
|
|
|
|
const managedPubkeyList = React.useMemo(
|
|
() => managedAgents.map((agent) => agent.pubkey),
|
|
[managedAgents],
|
|
);
|
|
|
|
const managedPresenceQuery = usePresenceQuery(managedPubkeyList);
|
|
|
|
const channelsByPubkey = React.useMemo(() => {
|
|
const map: Record<string, { id: string; name: string }[]> = {};
|
|
// Seed from relay agent profiles (kind:10100 events).
|
|
for (const ra of relayAgentsQuery.data ?? []) {
|
|
if (ra.channels.length > 0) {
|
|
// Skip entries missing a channel id rather than falling back to the
|
|
// name as id — a misaligned channels/channelIds pairing would otherwise
|
|
// produce a pill that silently navigates to a channel name as if it
|
|
// were an id.
|
|
map[normalizePubkey(ra.pubkey)] = ra.channels.flatMap((name, i) => {
|
|
const id = ra.channelIds[i];
|
|
return id ? [{ id, name }] : [];
|
|
});
|
|
}
|
|
}
|
|
// Fill in from channel member lists (kind:39002) for any managed agents
|
|
// not already covered by relay agent data.
|
|
const normalizedManaged = new Set(
|
|
managedAgents.map((a) => normalizePubkey(a.pubkey)),
|
|
);
|
|
for (const ch of channelsQuery.data ?? []) {
|
|
for (const pk of ch.memberPubkeys) {
|
|
const key = normalizePubkey(pk);
|
|
if (!normalizedManaged.has(key)) continue;
|
|
if (!map[key]) map[key] = [];
|
|
if (!map[key].some((entry) => entry.id === ch.id)) {
|
|
map[key].push({ id: ch.id, name: ch.name });
|
|
}
|
|
}
|
|
}
|
|
return map;
|
|
}, [relayAgentsQuery.data, channelsQuery.data, managedAgents]);
|
|
|
|
const channelIdToName = React.useMemo(() => {
|
|
const map: Record<string, string> = {};
|
|
for (const ch of channelsQuery.data ?? []) {
|
|
map[ch.id] = ch.name;
|
|
}
|
|
return map;
|
|
}, [channelsQuery.data]);
|
|
|
|
// Clear log selection if the agent was removed
|
|
React.useEffect(() => {
|
|
if (
|
|
logAgentPubkey &&
|
|
!managedAgents.some((agent) => agent.pubkey === logAgentPubkey)
|
|
) {
|
|
setLogAgentPubkey(null);
|
|
}
|
|
}, [managedAgents, logAgentPubkey]);
|
|
|
|
function clearFeedback() {
|
|
setActionNoticeMessage(null);
|
|
setActionErrorMessage(null);
|
|
}
|
|
|
|
async function handleStart(pubkey: string) {
|
|
clearFeedback();
|
|
try {
|
|
const agent = managedAgents.find((c) => c.pubkey === pubkey);
|
|
if (!agent) return;
|
|
await startManagedAgentWithRules({
|
|
agent,
|
|
startManagedAgent: startMutation.mutateAsync,
|
|
});
|
|
} catch (error) {
|
|
setActionErrorMessage(
|
|
error instanceof Error ? error.message : "Failed to start agent.",
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Create Agent wizard ────────────────────────────────────────────────
|
|
|
|
function openCreateStart() {
|
|
clearFeedback();
|
|
setIsCreateStartOpen(true);
|
|
}
|
|
|
|
function openCreateWithDraft(draft: AgentDraft) {
|
|
clearFeedback();
|
|
setIsCreateStartOpen(false);
|
|
setCreateDraft(draft);
|
|
}
|
|
|
|
function handlePickBlank() {
|
|
openCreateWithDraft(blankAgentDraft());
|
|
}
|
|
|
|
function handlePickTemplate(template: AgentTemplate) {
|
|
openCreateWithDraft(templateAgentDraft(template));
|
|
}
|
|
|
|
function openDuplicateAgent(agent: ManagedAgent) {
|
|
openCreateWithDraft(duplicateAgentDraft(agent));
|
|
}
|
|
|
|
async function handleImportAgentFile(fileBytes: number[], fileName: string) {
|
|
clearFeedback();
|
|
try {
|
|
const result = await parsePersonaFiles(fileBytes, fileName);
|
|
if (
|
|
isSingleItemFile(fileBytes, fileName) &&
|
|
result.personas.length === 1
|
|
) {
|
|
openCreateWithDraft(importAgentDraft(result.personas[0]));
|
|
} else if (result.personas.length > 0) {
|
|
setIsCreateStartOpen(false);
|
|
setBatchImportResult(result);
|
|
setBatchImportFileName(fileName);
|
|
} else {
|
|
setActionErrorMessage("No valid agents found in file.");
|
|
}
|
|
} catch (error) {
|
|
setActionErrorMessage(
|
|
error instanceof Error ? error.message : "Failed to parse agent file.",
|
|
);
|
|
}
|
|
}
|
|
|
|
function handleBatchImportComplete(count: number) {
|
|
clearFeedback();
|
|
setBatchImportResult(null);
|
|
setActionNoticeMessage(`Imported ${count} agent${count !== 1 ? "s" : ""}.`);
|
|
void managedAgentsQuery.refetch();
|
|
void relayAgentsQuery.refetch();
|
|
}
|
|
|
|
function handleExportAgent(agent: ManagedAgent) {
|
|
clearFeedback();
|
|
exportAgentJsonMutation.mutate(agent.pubkey, {
|
|
onSuccess: (saved) => {
|
|
if (saved) {
|
|
setActionNoticeMessage(`Exported ${agent.name}.`);
|
|
}
|
|
},
|
|
onError: (error) => {
|
|
setActionErrorMessage(
|
|
error instanceof Error ? error.message : "Failed to export agent.",
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
function handleSaveAsTemplate(agent: ManagedAgent) {
|
|
clearFeedback();
|
|
saveAsTemplateMutation.mutate(agent.pubkey, {
|
|
onSuccess: (template) => {
|
|
setActionNoticeMessage(
|
|
`Saved ${template.displayName} as a template. It will show under New agent.`,
|
|
);
|
|
},
|
|
onError: (error) => {
|
|
setActionErrorMessage(
|
|
error instanceof Error
|
|
? error.message
|
|
: "Failed to save agent as a template.",
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
async function getChannelsForAction() {
|
|
if (channelsQuery.data) {
|
|
return channelsQuery.data;
|
|
}
|
|
|
|
const result = await channelsQuery.refetch();
|
|
return result.data ?? [];
|
|
}
|
|
|
|
async function handleStop(pubkey: string) {
|
|
clearFeedback();
|
|
try {
|
|
const agent = managedAgents.find((a) => a.pubkey === pubkey);
|
|
if (!agent) return;
|
|
const channels = await getChannelsForAction();
|
|
const result = await stopManagedAgentWithRules({
|
|
agent,
|
|
channels,
|
|
relayAgents: relayAgentsQuery.data ?? [],
|
|
stopManagedAgent: stopMutation.mutateAsync,
|
|
});
|
|
if (result.noticeMessage) {
|
|
setActionNoticeMessage(result.noticeMessage);
|
|
}
|
|
} catch (error) {
|
|
setActionErrorMessage(
|
|
error instanceof Error ? error.message : "Failed to stop agent.",
|
|
);
|
|
}
|
|
}
|
|
|
|
function getAgentChannelIds(pubkey: string): string[] {
|
|
const normalized = normalizePubkey(pubkey);
|
|
const relayAgent = (relayAgentsQuery.data ?? []).find(
|
|
(ra) => normalizePubkey(ra.pubkey) === normalized,
|
|
);
|
|
return relayAgent?.channelIds ?? [];
|
|
}
|
|
|
|
async function removeAgentFromAllChannels(pubkey: string) {
|
|
const channelIds = getAgentChannelIds(pubkey);
|
|
if (channelIds.length === 0) return;
|
|
await Promise.allSettled(
|
|
channelIds.map((channelId) => removeChannelMember(channelId, pubkey)),
|
|
);
|
|
}
|
|
|
|
async function handleDelete(pubkey: string) {
|
|
clearFeedback();
|
|
try {
|
|
const agent = managedAgents.find((a) => a.pubkey === pubkey);
|
|
if (!agent) return;
|
|
const channels = await getChannelsForAction();
|
|
const result = await deleteManagedAgentWithRules({
|
|
agent,
|
|
channels,
|
|
deleteManagedAgent: deleteMutation.mutateAsync,
|
|
presenceLookup: managedPresenceQuery.data,
|
|
relayAgents: relayAgentsQuery.data ?? [],
|
|
});
|
|
if (result.cancelled) return;
|
|
await removeAgentFromAllChannels(pubkey);
|
|
if (logAgentPubkey === pubkey) {
|
|
setLogAgentPubkey(null);
|
|
}
|
|
} catch (error) {
|
|
setActionErrorMessage(
|
|
error instanceof Error ? error.message : "Failed to delete agent.",
|
|
);
|
|
}
|
|
}
|
|
|
|
async function handleToggleStartOnAppLaunch(
|
|
pubkey: string,
|
|
startOnAppLaunch: boolean,
|
|
) {
|
|
clearFeedback();
|
|
try {
|
|
const updated = await startOnLaunchMutation.mutateAsync({
|
|
pubkey,
|
|
startOnAppLaunch,
|
|
});
|
|
setActionNoticeMessage(
|
|
updated.startOnAppLaunch
|
|
? `Will start ${updated.name} automatically when the desktop app opens.`
|
|
: `${updated.name} will stay manual-start only.`,
|
|
);
|
|
} catch (error) {
|
|
setActionErrorMessage(
|
|
error instanceof Error
|
|
? error.message
|
|
: "Failed to update startup preference.",
|
|
);
|
|
}
|
|
}
|
|
|
|
function handleAddedToChannel(
|
|
channel: Channel,
|
|
result: AttachManagedAgentToChannelResult,
|
|
) {
|
|
setActionErrorMessage(null);
|
|
setActionNoticeMessage(() => {
|
|
if (result.started) {
|
|
return `Added ${result.agent.name} to ${channel.name} and spawned it.`;
|
|
}
|
|
if (result.membershipAdded) {
|
|
return `Added ${result.agent.name} to ${channel.name}.`;
|
|
}
|
|
return `${result.agent.name} is already in ${channel.name}.`;
|
|
});
|
|
void managedAgentsQuery.refetch();
|
|
void relayAgentsQuery.refetch();
|
|
}
|
|
|
|
async function runBulkAction(
|
|
targets: ManagedAgent[],
|
|
confirmLabel: string,
|
|
failureNoun: string,
|
|
action: (agent: ManagedAgent) => Promise<unknown>,
|
|
): Promise<boolean> {
|
|
if (targets.length === 0) return false;
|
|
const confirmed = window.confirm(
|
|
`${confirmLabel} ${targets.length} agent${targets.length === 1 ? "" : "s"}?`,
|
|
);
|
|
if (!confirmed) return false;
|
|
clearFeedback();
|
|
const results = await Promise.allSettled(targets.map(action));
|
|
const failures = results.filter((r) => r.status === "rejected");
|
|
if (failures.length > 0) {
|
|
setActionErrorMessage(
|
|
`${failures.length} of ${targets.length} ${failureNoun}${failures.length === 1 ? "" : "s"} failed.`,
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function handleBulkStopRunning() {
|
|
await runBulkAction(
|
|
managedAgents.filter((a) => isManagedAgentActive(a)),
|
|
"Stop",
|
|
"stop",
|
|
(a) =>
|
|
stopManagedAgentWithRules({
|
|
agent: a,
|
|
channels: channelsQuery.data ?? [],
|
|
relayAgents: relayAgentsQuery.data ?? [],
|
|
stopManagedAgent: stopMutation.mutateAsync,
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function handleBulkRemoveStopped() {
|
|
const targets = managedAgents.filter(
|
|
(a) => a.status === "stopped" || a.status === "not_deployed",
|
|
);
|
|
const executed = await runBulkAction(
|
|
targets,
|
|
"Remove",
|
|
"removal",
|
|
async (a) => {
|
|
await deleteManagedAgent(a);
|
|
await removeAgentFromAllChannels(a.pubkey);
|
|
},
|
|
);
|
|
if (
|
|
executed &&
|
|
logAgentPubkey &&
|
|
targets.some((a) => a.pubkey === logAgentPubkey)
|
|
) {
|
|
setLogAgentPubkey(null);
|
|
}
|
|
}
|
|
|
|
async function deleteManagedAgent(agent: ManagedAgent) {
|
|
const isDeployedRemote =
|
|
agent.backend.type === "provider" && agent.backendAgentId;
|
|
await deleteMutation.mutateAsync({
|
|
pubkey: agent.pubkey,
|
|
forceRemoteDelete: isDeployedRemote ? true : undefined,
|
|
});
|
|
}
|
|
|
|
const isPending =
|
|
startMutation.isPending ||
|
|
stopMutation.isPending ||
|
|
startOnLaunchMutation.isPending ||
|
|
exportAgentJsonMutation.isPending ||
|
|
saveAsTemplateMutation.isPending ||
|
|
deleteMutation.isPending;
|
|
const startingAgentPubkey =
|
|
startMutation.isPending && typeof startMutation.variables === "string"
|
|
? startMutation.variables
|
|
: null;
|
|
|
|
return {
|
|
relayAgentsQuery,
|
|
managedAgentsQuery,
|
|
managedAgentLogQuery,
|
|
managedPresenceQuery,
|
|
managedAgents,
|
|
managedPubkeys,
|
|
channelIdToName,
|
|
channelsByPubkey,
|
|
isPending,
|
|
isCreateStartOpen,
|
|
setIsCreateStartOpen,
|
|
createDraft,
|
|
setCreateDraft,
|
|
batchImportResult,
|
|
setBatchImportResult,
|
|
batchImportFileName,
|
|
agentToAddToChannel,
|
|
setAgentToAddToChannel,
|
|
createdAgent,
|
|
setCreatedAgent,
|
|
logAgentPubkey,
|
|
setLogAgentPubkey,
|
|
actionNoticeMessage,
|
|
setActionNoticeMessage,
|
|
actionErrorMessage,
|
|
setActionErrorMessage,
|
|
startingAgentPubkey,
|
|
openCreateStart,
|
|
handlePickBlank,
|
|
handlePickTemplate,
|
|
openDuplicateAgent,
|
|
handleImportAgentFile,
|
|
handleBatchImportComplete,
|
|
handleExportAgent,
|
|
handleSaveAsTemplate,
|
|
handleStart,
|
|
handleStop,
|
|
handleDelete,
|
|
handleToggleStartOnAppLaunch,
|
|
handleAddedToChannel,
|
|
handleBulkStopRunning,
|
|
handleBulkRemoveStopped,
|
|
refetchManagedAgents: () => void managedAgentsQuery.refetch(),
|
|
refetchRelayAgents: () => void relayAgentsQuery.refetch(),
|
|
};
|
|
}
|