Files
buzz/desktop/src/features/agents/ui/useTeamActions.ts
Taylor Ho 24ff20349b feat(desktop): remove personas from the UI — flat agents grid, create-agent wizard, teams pick agents
Agents are now the only concept users touch. Persona records remain a
pack-internal primitive; nothing in the UI creates, lists, or edits them.

- Agents grid: UnifiedAgentsSection renders one card per managed agent in
  a single flat grid with a new AgentActionsMenu (Edit, Duplicate, Add to
  channel, Export, Remove). Persona-keyed grouping, persona cards, and
  the persona profile-panel path are deleted (unifiedAgentGroups,
  AgentGroupRows, ManagedAgentRow, PersonaActionsMenu, PersonaIdentity,
  PersonaCatalog*, PersonaLibraryEntryPoints, usePersonaActions, and the
  persona dialog state helpers)
- Create Agent wizard: new CreateAgentStartDialog (blank, built-in
  template, or import from file) hands an AgentDraft to CreateAgentDialog;
  submit only creates a managed agent — no persona record. PersonaDialog
  is gone; batch import creates agents directly; the system prompt is a
  top-level "Agent instructions" field with model/provider fields shared
  via AgentModelProviderFields
- Duplicate opens the wizard prefilled from the source agent record's
  pinned fields (prompt, model, provider, env vars, avatar, runtime)
- Teams: TeamDialog picks managed agents and stores pubkeys via the
  unified TeamCardMember model (pack persona members render read-only);
  TeamsSection, import/export, and AddTeamToChannelDialog resolve members
  from agent_pubkeys plus pack persona_ids, deploying pack personas
  through the existing persona path
- Channel deploy: AddChannelBotDialog lists managed agents
  (AddChannelBotAgentsSection + useInChannelAgentPubkeys) and attaches
  existing agents instead of spawning persona instances; QuickBotBar and
  the persona section/runtime-override helpers are deleted. The dialog
  also prevents open-autofocus: focus landing on the first agent chip
  opened its tooltip, so the first Escape dismissed the tooltip layer
  instead of closing the dialog
- Sharing: PersonaShareDialog, catalog-visibility localStorage, and the
  standalone catalog dialog are removed — Export in the card menu is the
  sharing path
- Copy sweep: remaining user-visible "persona" strings renamed (config
  provenance is "Inherited from agent profile", delete/import/toast copy,
  ModelPicker, EnvVarsEditor); profile panel actions are agent-centric
  and the profilePersona route param is gone; channel templates resolve
  agent pubkeys, persona ids, or builtin template ids polymorphically;
  the Fizz welcome guide spawns from its AgentTemplate
- shared/api: AgentTemplate type, agentPubkeys on team types, provider on
  CreateManagedAgentInput, listAgentTemplates/exportAgentToJson bindings;
  e2eBridge mocks the new surface for the Playwright suites; colocated
  unit tests (.test.mjs) updated alongside their sources

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
2026-07-02 22:40:05 -07:00

622 lines
20 KiB
TypeScript

import * as React from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
managedAgentsQueryKey,
personasQueryKey,
teamsQueryKey,
useAvailableAcpRuntimes,
useCreateTeamMutation,
useDeleteTeamMutation,
useManagedAgentsQuery,
usePersonasQuery,
useTeamsQuery,
useUpdateTeamMutation,
} from "@/features/agents/hooks";
import type { CreateChannelManagedAgentsResult } from "@/features/agents/channelAgents";
import {
type ParsedTeamPreview,
createTeam as createTeamApi,
exportTeamToJson,
installTeamFromDirectory,
parseTeamFile,
pickTeamDirectory,
syncTeamDirectory,
} from "@/shared/api/tauriTeams";
import { deletePersona, updatePersona } from "@/shared/api/tauriPersonas";
import {
createManagedAgent,
deleteManagedAgent,
updateManagedAgent,
} from "@/shared/api/tauri";
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import type {
AcpRuntime,
AgentTeam,
Channel,
CreateTeamInput,
UpdateTeamInput,
} from "@/shared/api/types";
import type { RemovedTeamMemberRef } from "./TeamDialog";
import { buildTeamImportPlan } from "./teamImportPlan";
type TeamDialogState = {
description: string;
initialValues: CreateTeamInput | UpdateTeamInput;
submitLabel: string;
title: string;
} | null;
type ActionMessages = {
setActionNoticeMessage: (message: string | null) => void;
setActionErrorMessage: (message: string | null) => void;
};
type RefetchCallbacks = {
refetchManagedAgents: () => void;
refetchRelayAgents: () => void;
};
type TeamImportUpdateApplyInput = {
updateTeamInfo: boolean;
selectedUpdatedMemberIds: string[];
selectedNewMemberIndexes: number[];
missingMemberIdsToRemove: string[];
deleteRemovedAgents: boolean;
};
export function useTeamActions(
actions: ActionMessages,
refetch: RefetchCallbacks,
) {
const queryClient = useQueryClient();
const teamsQuery = useTeamsQuery();
const personasQuery = usePersonasQuery();
const managedAgentsQuery = useManagedAgentsQuery();
const availableRuntimesQuery = useAvailableAcpRuntimes();
const createTeamMutation = useCreateTeamMutation();
const updateTeamMutation = useUpdateTeamMutation();
const deleteTeamMutation = useDeleteTeamMutation();
const exportTeamJsonMutation = useMutation({
mutationFn: (id: string) => exportTeamToJson(id),
});
const [teamDialogState, setTeamDialogState] =
React.useState<TeamDialogState>(null);
const [teamToDelete, setTeamToDelete] = React.useState<AgentTeam | null>(
null,
);
const [teamToAddToChannel, setTeamToAddToChannel] =
React.useState<AgentTeam | null>(null);
const [teamImportPreview, setTeamImportPreview] = React.useState<{
preview: ParsedTeamPreview;
fileName: string;
} | null>(null);
const [teamImportTarget, setTeamImportTarget] =
React.useState<AgentTeam | null>(null);
const [teamImportTargetPreview, setTeamImportTargetPreview] = React.useState<{
preview: ParsedTeamPreview;
fileName: string;
} | null>(null);
const [isApplyingTeamImportUpdate, setIsApplyingTeamImportUpdate] =
React.useState(false);
const teams = teamsQuery.data ?? [];
async function getImportRuntimes(): Promise<AcpRuntime[]> {
if (availableRuntimesQuery.isFetched) {
return availableRuntimesQuery.data ?? [];
}
const result = await availableRuntimesQuery.refetch();
return (result.data ?? []).filter(
(runtime): runtime is AcpRuntime => runtime.availability === "available",
);
}
async function handleTeamSubmit(input: CreateTeamInput | UpdateTeamInput) {
actions.setActionNoticeMessage(null);
actions.setActionErrorMessage(null);
try {
if ("id" in input) {
await updateTeamMutation.mutateAsync(input);
actions.setActionNoticeMessage(`Updated team "${input.name}".`);
} else {
await createTeamMutation.mutateAsync(input);
actions.setActionNoticeMessage(`Created team "${input.name}".`);
}
setTeamDialogState(null);
} catch (error) {
actions.setActionErrorMessage(
error instanceof Error ? error.message : "Failed to save team.",
);
}
}
async function handleDeleteTeam(team: AgentTeam) {
actions.setActionNoticeMessage(null);
actions.setActionErrorMessage(null);
try {
await deleteTeamMutation.mutateAsync(team.id);
actions.setActionNoticeMessage(`Deleted team "${team.name}".`);
setTeamToDelete(null);
} catch (error) {
actions.setActionErrorMessage(
error instanceof Error ? error.message : "Failed to delete team.",
);
}
}
function handleTeamDeployed(
channel: Channel,
result: CreateChannelManagedAgentsResult,
) {
actions.setActionErrorMessage(null);
const successCount = result.successes.length;
const failCount = result.failures.length;
if (failCount === 0) {
actions.setActionNoticeMessage(
`Deployed ${successCount} ${successCount === 1 ? "agent" : "agents"} to ${channel.name}.`,
);
} else {
actions.setActionNoticeMessage(
`Deployed ${successCount} ${successCount === 1 ? "agent" : "agents"} to ${channel.name}. ${failCount} failed.`,
);
}
setTeamToAddToChannel(null);
refetch.refetchManagedAgents();
refetch.refetchRelayAgents();
}
function openCreateDialog() {
actions.setActionNoticeMessage(null);
actions.setActionErrorMessage(null);
setTeamDialogState({
title: "Create team",
description: "Group agents together for quick deployment to channels.",
submitLabel: "Create team",
initialValues: {
name: "",
description: "",
personaIds: [],
agentPubkeys: [],
},
});
}
function openDuplicateDialog(team: AgentTeam) {
actions.setActionNoticeMessage(null);
actions.setActionErrorMessage(null);
setTeamDialogState({
title: `Duplicate ${team.name}`,
description: "Create a new team by copying this one.",
submitLabel: "Create team",
initialValues: {
name: `${team.name} copy`,
description: team.description ?? "",
personaIds: [...team.personaIds],
agentPubkeys: [...team.agentPubkeys],
},
});
}
function handleExportTeam(team: AgentTeam) {
exportTeamJsonMutation.mutate(team.id, {
onSuccess: (saved) => {
if (saved) {
actions.setActionNoticeMessage(`Exported team "${team.name}".`);
}
},
onError: (err) => {
actions.setActionErrorMessage(
err instanceof Error ? err.message : "Failed to export team.",
);
},
});
}
async function handleImportFile(fileBytes: number[], fileName: string) {
actions.setActionNoticeMessage(null);
actions.setActionErrorMessage(null);
try {
const preview = await parseTeamFile(fileBytes, fileName);
setTeamImportPreview({ preview, fileName });
} catch (err) {
actions.setActionErrorMessage(
err instanceof Error ? err.message : "Failed to parse team file.",
);
}
}
async function handleInstallFromDirectory() {
actions.setActionNoticeMessage(null);
actions.setActionErrorMessage(null);
try {
const path = await pickTeamDirectory();
if (!path) return;
const team = await installTeamFromDirectory(path, true);
actions.setActionNoticeMessage(
`Installed team "${team.name}" from directory.`,
);
await Promise.all([
queryClient.invalidateQueries({ queryKey: teamsQueryKey }),
queryClient.invalidateQueries({ queryKey: personasQueryKey }),
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
]);
setTeamDialogState(null);
} catch (err) {
actions.setActionErrorMessage(
err instanceof Error
? err.message
: "Failed to install team from directory.",
);
}
}
async function handleSyncTeam(team: AgentTeam) {
actions.setActionNoticeMessage(null);
actions.setActionErrorMessage(null);
try {
const result = await syncTeamDirectory(team.id);
const changes = [
result.personas_added.length > 0 &&
`${result.personas_added.length} added`,
result.personas_updated.length > 0 &&
`${result.personas_updated.length} updated`,
result.personas_removed.length > 0 &&
`${result.personas_removed.length} removed`,
].filter(Boolean);
const summary =
changes.length > 0 ? changes.join(", ") : "already up to date";
actions.setActionNoticeMessage(`Synced "${team.name}": ${summary}.`);
await Promise.all([
queryClient.invalidateQueries({ queryKey: teamsQueryKey }),
queryClient.invalidateQueries({ queryKey: personasQueryKey }),
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
]);
} catch (err) {
actions.setActionErrorMessage(
err instanceof Error ? err.message : "Failed to sync team directory.",
);
}
}
function handleRevealInFinder(team: AgentTeam) {
if (!team.sourceDir) return;
void revealItemInDir(team.sourceDir);
}
async function handleEditDialogImportUpdateFile(
teamId: string,
fileBytes: number[],
fileName: string,
) {
actions.setActionNoticeMessage(null);
actions.setActionErrorMessage(null);
const team = teams.find((candidate) => candidate.id === teamId);
if (!team) {
const message = "Team not found. Refresh and try again.";
actions.setActionErrorMessage(message);
throw new Error(message);
}
try {
const preview = await parseTeamFile(fileBytes, fileName);
setTeamImportTarget(team);
setTeamImportTargetPreview({ preview, fileName });
setTeamDialogState(null);
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to parse team file.";
actions.setActionErrorMessage(message);
throw err instanceof Error ? err : new Error(message);
}
}
function closeImportUpdateDialog() {
setTeamImportTarget(null);
setTeamImportTargetPreview(null);
setIsApplyingTeamImportUpdate(false);
}
function clearImportUpdateAndReturnToEdit() {
if (!teamImportTarget) {
closeImportUpdateDialog();
return;
}
const team = teamImportTarget;
closeImportUpdateDialog();
openEditDialog(team);
}
async function handleTeamImportUpdateApply({
updateTeamInfo,
selectedUpdatedMemberIds,
selectedNewMemberIndexes,
missingMemberIdsToRemove,
deleteRemovedAgents,
}: TeamImportUpdateApplyInput) {
if (!teamImportTarget || !teamImportTargetPreview) {
throw new Error("No team import update is currently open.");
}
actions.setActionNoticeMessage(null);
actions.setActionErrorMessage(null);
setIsApplyingTeamImportUpdate(true);
const personas = personasQuery.data ?? [];
const agents = managedAgentsQuery.data ?? [];
const plan = buildTeamImportPlan({
team: teamImportTarget,
personas,
agents,
preview: teamImportTargetPreview.preview,
});
const selectedUpdatedMemberIdSet = new Set(selectedUpdatedMemberIds);
const selectedNewMemberIndexSet = new Set(selectedNewMemberIndexes);
const removeMemberIdSet = new Set(missingMemberIdsToRemove);
try {
let updatedMembersCount = 0;
for (const member of plan.membersToUpdate) {
if (!selectedUpdatedMemberIdSet.has(member.existing.id)) {
continue;
}
if (member.existing.kind === "persona") {
const persona = personas.find((p) => p.id === member.existing.id);
await updatePersona({
id: member.existing.id,
displayName: member.imported.display_name,
systemPrompt: member.imported.system_prompt,
avatarUrl: member.imported.avatar_url ?? undefined,
runtime: persona?.runtime ?? undefined,
model: persona?.model ?? undefined,
namePool: persona ? [...persona.namePool] : [],
});
} else {
await updateManagedAgent({
pubkey: member.existing.id,
name: member.imported.display_name,
systemPrompt: member.imported.system_prompt || undefined,
});
}
updatedMembersCount += 1;
}
// New members are created as (stopped) managed agents.
const runtimes = await getImportRuntimes();
const createdPubkeysByImportedIndex = new Map<number, string>();
for (const member of plan.newMembers) {
if (!selectedNewMemberIndexSet.has(member.importedIndex)) {
continue;
}
const runtime = runtimes[0];
if (!runtime) {
throw new Error(
"No available agent runtime found. Visit Settings > Doctor to set one up.",
);
}
const created = await createManagedAgent({
name: member.imported.display_name,
acpCommand: "buzz-acp",
agentCommand: runtime.command,
agentArgs: runtime.defaultArgs,
mcpCommand: runtime.mcpCommand ?? "",
systemPrompt: member.imported.system_prompt || undefined,
avatarUrl: member.imported.avatar_url ?? undefined,
spawnAfterCreate: false,
startOnAppLaunch: false,
backend: { type: "local" },
});
createdPubkeysByImportedIndex.set(
member.importedIndex,
created.agent.pubkey,
);
}
const nextPersonaIds: string[] = [];
const nextAgentPubkeys: string[] = [];
const pushMember = (kind: "agent" | "persona", id: string) => {
if (kind === "persona") {
nextPersonaIds.push(id);
} else {
nextAgentPubkeys.push(id);
}
};
for (const member of plan.matchedMembers) {
pushMember(member.existing.kind, member.existing.id);
}
for (const pubkey of createdPubkeysByImportedIndex.values()) {
pushMember("agent", pubkey);
}
const removedMembers = plan.missingMembers.filter((member) =>
removeMemberIdSet.has(member.existing.id),
);
const keptMissingMembers = plan.missingMembers.filter(
(member) => !removeMemberIdSet.has(member.existing.id),
);
for (const member of keptMissingMembers) {
pushMember(member.existing.kind, member.existing.id);
}
const nextTeamName = updateTeamInfo
? teamImportTargetPreview.preview.name
: teamImportTarget.name;
const nextTeamDescription = updateTeamInfo
? (teamImportTargetPreview.preview.description ?? undefined)
: (teamImportTarget.description ?? undefined);
await updateTeamMutation.mutateAsync({
id: teamImportTarget.id,
name: nextTeamName,
description: nextTeamDescription,
personaIds: nextPersonaIds,
agentPubkeys: nextAgentPubkeys,
});
let deletedAgentsCount = 0;
const deleteFailures: string[] = [];
const addedMembersCount = createdPubkeysByImportedIndex.size;
if (deleteRemovedAgents && removedMembers.length > 0) {
for (const member of removedMembers) {
try {
if (member.existing.kind === "persona") {
await deletePersona(member.existing.id);
} else {
await deleteManagedAgent(member.existing.id);
}
deletedAgentsCount += 1;
} catch (error) {
const reason =
error instanceof Error ? error.message : String(error);
deleteFailures.push(`${member.existing.displayName}: ${reason}`);
}
}
}
actions.setActionNoticeMessage(
`Updated "${nextTeamName}" from import. ${updatedMembersCount} member${updatedMembersCount === 1 ? "" : "s"} updated, ${addedMembersCount} added, ${removedMembers.length} removed from the team${deleteRemovedAgents ? `, and ${deletedAgentsCount} deleted` : ""}.`,
);
if (deleteFailures.length > 0) {
actions.setActionErrorMessage(
`Team updated, but ${deleteFailures.length} agent${deleteFailures.length === 1 ? "" : "s"} could not be removed: ${deleteFailures.join("; ")}`,
);
}
closeImportUpdateDialog();
await Promise.all([
queryClient.invalidateQueries({ queryKey: personasQueryKey }),
queryClient.invalidateQueries({ queryKey: teamsQueryKey }),
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
]);
} catch (error) {
const message =
error instanceof Error
? error.message
: "Failed to apply imported team update.";
actions.setActionErrorMessage(message);
throw error instanceof Error ? error : new Error(message);
} finally {
setIsApplyingTeamImportUpdate(false);
}
}
function handleTeamImportComplete(
teamName: string,
teamDescription: string | null,
agentPubkeys: string[],
) {
setTeamImportPreview(null);
void (async () => {
const teamInput = {
name: teamName,
description: teamDescription ?? undefined,
personaIds: [],
agentPubkeys,
};
// Try creating the team, retry once on failure.
for (let attempt = 0; attempt < 2; attempt++) {
try {
await createTeamApi(teamInput);
actions.setActionNoticeMessage(
`Imported team "${teamName}" with ${agentPubkeys.length} agent${agentPubkeys.length !== 1 ? "s" : ""}.`,
);
void queryClient.invalidateQueries({
queryKey: managedAgentsQueryKey,
});
void queryClient.invalidateQueries({ queryKey: teamsQueryKey });
return;
} catch {
if (attempt === 0) continue;
}
}
// Both attempts failed — agents exist but team doesn't.
actions.setActionErrorMessage(
`Imported ${agentPubkeys.length} agent${agentPubkeys.length !== 1 ? "s" : ""} but failed to create team "${teamName}". The agents are saved — create a team manually to group them.`,
);
void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey });
})();
}
async function handleDeleteRemovedMembers(members: RemovedTeamMemberRef[]) {
for (const member of members) {
try {
if (member.kind === "persona") {
await deletePersona(member.id);
} else {
await deleteManagedAgent(member.id);
}
} catch {
// Best-effort: the member may already be deleted or in use elsewhere.
}
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: personasQueryKey }),
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
]);
}
function openEditDialog(team: AgentTeam) {
actions.setActionNoticeMessage(null);
actions.setActionErrorMessage(null);
setTeamDialogState({
title: "Edit team",
description: "",
submitLabel: "Save changes",
initialValues: {
id: team.id,
name: team.name,
description: team.description ?? "",
personaIds: [...team.personaIds],
agentPubkeys: [...team.agentPubkeys],
},
});
}
return {
teams,
teamsQuery,
createTeamMutation,
updateTeamMutation,
deleteTeamMutation,
exportTeamJsonMutation,
teamDialogState,
setTeamDialogState,
teamToDelete,
setTeamToDelete,
teamToAddToChannel,
setTeamToAddToChannel,
teamImportPreview,
setTeamImportPreview,
teamImportTarget,
teamImportTargetPreview,
isApplyingTeamImportUpdate,
handleTeamSubmit,
handleDeleteRemovedMembers,
handleDeleteTeam,
handleTeamDeployed,
handleExportTeam,
handleImportFile,
handleInstallFromDirectory,
handleSyncTeam,
handleRevealInFinder,
handleEditDialogImportUpdateFile,
handleTeamImportComplete,
handleTeamImportUpdateApply,
closeImportUpdateDialog,
clearImportUpdateAndReturnToEdit,
openCreateDialog,
openDuplicateDialog,
openEditDialog,
};
}