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>
This commit is contained in:
Taylor Ho
2026-07-02 22:40:05 -07:00
parent ce88863d78
commit 24ff20349b
98 changed files with 2914 additions and 8846 deletions

View File

@@ -707,7 +707,9 @@ export function AppShell() {
onRemoveWorkspace={workspacesHook.removeWorkspace}
onSwitchWorkspace={workspacesHook.switchWorkspace}
onCreateAgent={() =>
void goAgents().then(requestOpenCreateAgent)
void goAgents().then(() => {
requestOpenCreateAgent();
})
}
selfPresenceStatus={presenceSession.currentStatus}
workspaces={workspacesHook.workspaces}

View File

@@ -11,7 +11,6 @@ import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
type AgentsRouteSearch = {
profile?: string;
profilePersona?: string;
profileTab?: ProfilePanelTab;
profileView?: ProfilePanelView;
};
@@ -25,7 +24,6 @@ function validateAgentsSearch(
): AgentsRouteSearch {
return {
profile: nonEmptyString(search.profile),
profilePersona: nonEmptyString(search.profilePersona),
profileTab: parseProfilePanelTab(search.profileTab) ?? undefined,
profileView: parseProfilePanelView(search.profileView) ?? undefined,
};

View File

@@ -30,7 +30,11 @@ import {
setPersonaActive,
updatePersona,
} from "@/shared/api/tauriPersonas";
import { setManagedAgentStartOnAppLaunch } from "@/shared/api/tauriManagedAgents";
import {
exportAgentToJson,
listAgentTemplates,
setManagedAgentStartOnAppLaunch,
} from "@/shared/api/tauriManagedAgents";
import {
createTeam,
deleteTeam,
@@ -75,6 +79,7 @@ export const managedAgentsQueryKey = ["managed-agents"] as const;
export const personasQueryKey = ["personas"] as const;
export const teamsQueryKey = ["teams"] as const;
export const acpRuntimesQueryKey = ["acp-runtimes"] as const;
export const agentTemplatesQueryKey = ["agent-templates"] as const;
export const managedAgentPrereqsQueryKey = ["managed-agent-prereqs"] as const;
export const backendProvidersQueryKey = ["backend-providers"] as const;
@@ -175,6 +180,22 @@ export function usePersonasQuery() {
});
}
export function useAgentTemplatesQuery(options?: { enabled?: boolean }) {
return useQuery({
enabled: options?.enabled ?? true,
queryKey: agentTemplatesQueryKey,
queryFn: listAgentTemplates,
// Static built-in data — never refetch.
staleTime: Number.POSITIVE_INFINITY,
});
}
export function useExportAgentJsonMutation() {
return useMutation({
mutationFn: (pubkey: string) => exportAgentToJson(pubkey),
});
}
export function useManagedAgentPrereqsQuery(
acpCommand: string,
mcpCommand: string,

View File

@@ -1,159 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getCatalogPersonas,
getCatalogSelectionState,
getLibraryPersonas,
getPersonaLabelsById,
getPersonaLibraryState,
isCatalogPersonaSelected,
} from "./catalog.ts";
function createPersona(id, displayName, overrides = {}) {
return {
id,
displayName,
avatarUrl: overrides.avatarUrl ?? null,
systemPrompt: overrides.systemPrompt ?? `${displayName} prompt`,
runtime: overrides.runtime ?? null,
model: overrides.model ?? null,
isBuiltIn: overrides.isBuiltIn ?? false,
isActive: overrides.isActive ?? true,
createdAt: overrides.createdAt ?? "2026-01-01T00:00:00Z",
updatedAt: overrides.updatedAt ?? "2026-01-01T00:00:00Z",
};
}
test("getCatalogPersonas keeps built-ins visible whether selected or not", () => {
const personas = [
createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: false }),
createPersona("custom:builder", "Builder"),
];
assert.deepEqual(
getCatalogPersonas(personas).map((persona) => persona.id),
["builtin:fizz"],
);
});
test("getCatalogSelectionState keeps built-in selection rules in one place", () => {
const personas = [
createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }),
createPersona("custom:builder", "Builder"),
];
const state = getCatalogSelectionState(personas);
assert.deepEqual(
state.catalogPersonas.map((persona) => persona.id),
["builtin:fizz"],
);
assert.deepEqual(
state.selectedCatalogPersonas.map((persona) => persona.id),
["builtin:fizz"],
);
assert.deepEqual(
state.unselectedCatalogPersonas.map((persona) => persona.id),
[],
);
});
test("getCatalogPersonas keeps chooser order stable when selection changes", () => {
const inactive = [
createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: false }),
createPersona("builtin:reviewer", "Reviewer", {
isBuiltIn: true,
isActive: true,
}),
];
const active = [
createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }),
createPersona("builtin:reviewer", "Reviewer", {
isBuiltIn: true,
isActive: false,
}),
];
assert.deepEqual(
getCatalogPersonas(inactive).map((persona) => persona.id),
getCatalogPersonas(active).map((persona) => persona.id),
);
});
test("isCatalogPersonaSelected treats active catalog personas as selected", () => {
assert.equal(
isCatalogPersonaSelected(
createPersona("builtin:fizz", "Fizz", {
isBuiltIn: true,
isActive: true,
}),
),
true,
);
assert.equal(
isCatalogPersonaSelected(
createPersona("builtin:fizz", "Fizz", {
isBuiltIn: true,
isActive: false,
}),
),
false,
);
assert.equal(
isCatalogPersonaSelected(createPersona("custom:builder", "Builder")),
true,
);
});
test("getPersonaLabelsById keeps every returned persona addressable", () => {
const personas = [
createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: false }),
createPersona("custom:builder", "Builder"),
];
assert.deepEqual(getPersonaLabelsById(personas), {
"builtin:fizz": "Fizz",
"custom:builder": "Builder",
});
});
test("getPersonaLibraryState keeps the working library and full catalog in one place", () => {
const personas = [
createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }),
createPersona("custom:builder", "Builder"),
];
const state = getPersonaLibraryState(personas);
assert.deepEqual(
state.libraryPersonas.map((persona) => persona.id),
["builtin:fizz", "custom:builder"],
);
assert.deepEqual(
state.catalogPersonas.map((persona) => persona.id),
["builtin:fizz"],
);
assert.equal(state.personaLabelsById["builtin:fizz"], "Fizz");
});
test("getLibraryPersonas keeps active custom personas even when catalog entries are similar", () => {
const avatarUrl = "https://example.test/coordinator.png";
const personas = [
createPersona("builtin:work-coordinator", "Work Coordinator", {
avatarUrl,
isBuiltIn: true,
isActive: false,
}),
createPersona("custom:work-coordinator", "Work Coordinator", {
avatarUrl,
isActive: true,
}),
createPersona("custom:builder", "Builder", { isActive: true }),
];
assert.deepEqual(
getLibraryPersonas(personas).map((persona) => persona.id),
["custom:work-coordinator", "custom:builder"],
);
});

View File

@@ -1,85 +0,0 @@
import type { AgentPersona } from "@/shared/api/types";
export type CatalogSelectionState = {
catalogPersonas: AgentPersona[];
selectedCatalogPersonas: AgentPersona[];
unselectedCatalogPersonas: AgentPersona[];
};
export type PersonaLibraryState = {
catalogPersonas: AgentPersona[];
libraryPersonas: AgentPersona[];
personaLabelsById: Record<string, string>;
};
export function isPersonaActive(persona: AgentPersona) {
return persona.isActive;
}
export function getActivePersonas(personas: readonly AgentPersona[]) {
return personas.filter(isPersonaActive);
}
export function getLibraryPersonas(personas: readonly AgentPersona[]) {
return getActivePersonas(personas);
}
export function isPersonaVisibleInCatalog(
persona: AgentPersona,
sharedCatalogPersonaIds: ReadonlySet<string> = new Set(),
) {
return persona.isBuiltIn || sharedCatalogPersonaIds.has(persona.id);
}
export function getCatalogPersonas(
personas: readonly AgentPersona[],
sharedCatalogPersonaIds: ReadonlySet<string> = new Set(),
) {
return personas
.filter((persona) =>
isPersonaVisibleInCatalog(persona, sharedCatalogPersonaIds),
)
.sort((left, right) => left.displayName.localeCompare(right.displayName));
}
export function isCatalogPersonaSelected(persona: AgentPersona) {
return persona.isActive;
}
export function getCatalogSelectionState(
personas: readonly AgentPersona[],
sharedCatalogPersonaIds: ReadonlySet<string> = new Set(),
): CatalogSelectionState {
const catalogPersonas = getCatalogPersonas(personas, sharedCatalogPersonaIds);
return {
catalogPersonas,
selectedCatalogPersonas: catalogPersonas.filter(isCatalogPersonaSelected),
unselectedCatalogPersonas: catalogPersonas.filter(
(persona) => !isCatalogPersonaSelected(persona),
),
};
}
export function getPersonaLabelsById(personas: readonly AgentPersona[]) {
return Object.fromEntries(
personas.map((persona) => [persona.id, persona.displayName]),
);
}
export function getPersonaLibraryState(
personas: readonly AgentPersona[],
sharedCatalogPersonaIds: ReadonlySet<string> = new Set(),
): PersonaLibraryState {
const libraryPersonas = getLibraryPersonas(personas);
const { catalogPersonas } = getCatalogSelectionState(
personas,
sharedCatalogPersonaIds,
);
return {
catalogPersonas,
libraryPersonas,
personaLabelsById: getPersonaLabelsById(personas),
};
}

View File

@@ -73,7 +73,7 @@ export function resolvePersonaRuntime(
return {
runtime: defaultRuntime,
warnings: [
`Persona is configured for runtime "${personaRuntimeId}" but it is not available. Using ${defaultRuntime.label} instead.`,
`This agent is configured for runtime "${personaRuntimeId}" but it is not available. Using ${defaultRuntime.label} instead.`,
],
isOverridden: true,
};
@@ -82,7 +82,7 @@ export function resolvePersonaRuntime(
return {
runtime: null,
warnings: [
`Persona is configured for runtime "${personaRuntimeId}" but it is not available, and no other runtimes were found.`,
`This agent is configured for runtime "${personaRuntimeId}" but it is not available, and no other runtimes were found.`,
],
isOverridden: false,
};

View File

@@ -0,0 +1,149 @@
import type { AgentPersona, AgentTeam, ManagedAgent } from "@/shared/api/types";
/**
* Unified team-member model. A team member is either a managed agent
* (referenced by pubkey — the primary membership for user teams) or a pack
* persona (referenced by persona id — pack-installed teams only).
*/
export type TeamMemberKind = "agent" | "persona";
export type TeamCardMember = {
key: string;
kind: TeamMemberKind;
/** Agent pubkey or persona id. */
id: string;
displayName: string;
avatarUrl: string | null;
model: string | null;
};
export function agentMemberKey(pubkey: string) {
return `agent:${pubkey}`;
}
export function personaMemberKey(personaId: string) {
return `persona:${personaId}`;
}
export function memberFromAgent(agent: ManagedAgent): TeamCardMember {
return {
key: agentMemberKey(agent.pubkey),
kind: "agent",
id: agent.pubkey,
displayName: agent.name,
avatarUrl: agent.avatarUrl,
model: agent.model,
};
}
export function memberFromPersona(persona: AgentPersona): TeamCardMember {
return {
key: personaMemberKey(persona.id),
kind: "persona",
id: persona.id,
displayName: persona.displayName,
avatarUrl: persona.avatarUrl,
model: persona.model,
};
}
/** Split a selected member-key list back into the wire fields. */
export function splitMemberKeys(keys: readonly string[]): {
personaIds: string[];
agentPubkeys: string[];
} {
const personaIds: string[] = [];
const agentPubkeys: string[] = [];
for (const key of keys) {
if (key.startsWith("agent:")) {
agentPubkeys.push(key.slice("agent:".length));
} else if (key.startsWith("persona:")) {
personaIds.push(key.slice("persona:".length));
}
}
return { personaIds, agentPubkeys };
}
export function teamMemberKeys(
team: Pick<AgentTeam, "personaIds" | "agentPubkeys">,
): string[] {
return [
...team.personaIds.map(personaMemberKey),
...team.agentPubkeys.map(agentMemberKey),
];
}
export type ResolvedTeamMembers = {
hasMissingMembers: boolean;
isComplete: boolean;
isUsable: boolean;
memberCount: number;
missingMemberCount: number;
resolvedMembers: TeamCardMember[];
resolvedPersonas: AgentPersona[];
resolvedAgents: ManagedAgent[];
};
/**
* Resolve a team's membership against the local persona and agent stores.
* Missing members (an agent that only exists on another device, or a persona
* that was removed) are counted but not resolved.
*/
export function resolveTeamMembers(
team: Pick<AgentTeam, "personaIds" | "agentPubkeys">,
personas: AgentPersona[],
agents: ManagedAgent[],
): ResolvedTeamMembers {
const personasById = new Map(
personas.map((persona) => [persona.id, persona]),
);
const agentsByPubkey = new Map(agents.map((agent) => [agent.pubkey, agent]));
const resolvedMembers: TeamCardMember[] = [];
const resolvedPersonas: AgentPersona[] = [];
const resolvedAgents: ManagedAgent[] = [];
let missingMemberCount = 0;
for (const personaId of team.personaIds) {
const persona = personasById.get(personaId);
if (persona) {
resolvedPersonas.push(persona);
resolvedMembers.push(memberFromPersona(persona));
} else {
missingMemberCount += 1;
}
}
for (const pubkey of team.agentPubkeys) {
const agent = agentsByPubkey.get(pubkey);
if (agent) {
resolvedAgents.push(agent);
resolvedMembers.push(memberFromAgent(agent));
} else {
missingMemberCount += 1;
}
}
const memberCount = team.personaIds.length + team.agentPubkeys.length;
return {
hasMissingMembers: missingMemberCount > 0,
isComplete: missingMemberCount === 0,
isUsable: missingMemberCount === 0 && resolvedMembers.length > 0,
memberCount,
missingMemberCount,
resolvedMembers,
resolvedPersonas,
resolvedAgents,
};
}
export function getUsableTeams(
teams: readonly AgentTeam[],
personas: AgentPersona[],
agents: ManagedAgent[],
) {
return teams.filter(
(team) => resolveTeamMembers(team, personas, agents).isUsable,
);
}

View File

@@ -1,94 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
emptyResolvedTeamPersonas,
getUsableTeams,
resolveTeamPersonas,
} from "./teamPersonas.ts";
function createPersona(id, displayName) {
return {
id,
displayName,
avatarUrl: null,
systemPrompt: `${displayName} prompt`,
runtime: null,
model: null,
isBuiltIn: false,
isActive: true,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
};
}
function createTeam(id, personaIds) {
return {
id,
name: `Team ${id}`,
description: null,
personaIds,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
};
}
test("emptyResolvedTeamPersonas starts empty but complete", () => {
assert.deepEqual(emptyResolvedTeamPersonas(), {
hasMissingPersonas: false,
isComplete: true,
isUsable: false,
missingPersonaCount: 0,
missingPersonaIds: [],
resolvedPersonaIds: [],
resolvedPersonas: [],
});
});
test("resolveTeamPersonas preserves team order and marks complete teams usable", () => {
const personas = [
createPersona("persona-1", "Solo"),
createPersona("persona-2", "Kit"),
];
const resolution = resolveTeamPersonas(
createTeam("team-1", ["persona-2", "persona-1"]),
personas,
);
assert.equal(resolution.isComplete, true);
assert.equal(resolution.isUsable, true);
assert.deepEqual(resolution.resolvedPersonaIds, ["persona-2", "persona-1"]);
assert.deepEqual(
resolution.resolvedPersonas.map((persona) => persona.displayName),
["Kit", "Solo"],
);
});
test("resolveTeamPersonas surfaces missing persona ids without dropping the resolved ones", () => {
const personas = [createPersona("persona-1", "Solo")];
const resolution = resolveTeamPersonas(
createTeam("team-2", ["persona-1", "missing"]),
personas,
);
assert.equal(resolution.hasMissingPersonas, true);
assert.equal(resolution.isComplete, false);
assert.equal(resolution.isUsable, false);
assert.equal(resolution.missingPersonaCount, 1);
assert.deepEqual(resolution.missingPersonaIds, ["missing"]);
assert.deepEqual(resolution.resolvedPersonaIds, ["persona-1"]);
});
test("getUsableTeams keeps only fully-resolved teams with at least one persona", () => {
const personas = [createPersona("persona-1", "Solo")];
const teams = [
createTeam("team-empty", []),
createTeam("team-missing", ["missing"]),
createTeam("team-ready", ["persona-1"]),
];
assert.deepEqual(
getUsableTeams(teams, personas).map((team) => team.id),
["team-ready"],
);
});

View File

@@ -1,74 +0,0 @@
import type { AgentPersona, AgentTeam } from "@/shared/api/types";
export type ResolvedTeamPersonas = {
hasMissingPersonas: boolean;
isComplete: boolean;
isUsable: boolean;
missingPersonaCount: number;
missingPersonaIds: string[];
resolvedPersonaIds: string[];
resolvedPersonas: AgentPersona[];
};
export function emptyResolvedTeamPersonas(): ResolvedTeamPersonas {
return {
hasMissingPersonas: false,
isComplete: true,
isUsable: false,
missingPersonaCount: 0,
missingPersonaIds: [],
resolvedPersonaIds: [],
resolvedPersonas: [],
};
}
export function isResolvedTeamUsable(
resolution: Pick<ResolvedTeamPersonas, "isComplete" | "resolvedPersonaIds">,
) {
return resolution.isComplete && resolution.resolvedPersonaIds.length > 0;
}
export function getUsableTeams(
teams: readonly AgentTeam[],
personas: AgentPersona[],
) {
return teams.filter((team) =>
isResolvedTeamUsable(resolveTeamPersonas(team, personas)),
);
}
export function resolveTeamPersonas(
team: Pick<AgentTeam, "personaIds">,
personas: AgentPersona[],
): ResolvedTeamPersonas {
const personasById = new Map(
personas.map((persona) => [persona.id, persona]),
);
const resolvedPersonas: AgentPersona[] = [];
const resolvedPersonaIds: string[] = [];
const missingPersonaIds: string[] = [];
for (const personaId of team.personaIds) {
const persona = personasById.get(personaId);
if (persona) {
resolvedPersonas.push(persona);
resolvedPersonaIds.push(persona.id);
continue;
}
missingPersonaIds.push(personaId);
}
const missingPersonaCount = missingPersonaIds.length;
return {
hasMissingPersonas: missingPersonaCount > 0,
isComplete: missingPersonaCount === 0,
isUsable: missingPersonaCount === 0 && resolvedPersonaIds.length > 0,
missingPersonaCount,
missingPersonaIds,
resolvedPersonaIds,
resolvedPersonas,
};
}

View File

@@ -1,63 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { pickQuickBotPersonas } from "./useBotRecents.ts";
function createPersona(id, displayName) {
return {
id,
displayName,
avatarUrl: null,
systemPrompt: `${displayName} prompt`,
runtime: null,
model: null,
isBuiltIn: true,
isActive: true,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
};
}
test("pickQuickBotPersonas prefers recents before defaults", () => {
const personas = [
createPersona("builtin:fizz", "Fizz"),
createPersona("builtin:reviewer", "Reviewer"),
];
assert.deepEqual(
pickQuickBotPersonas(personas, ["builtin:reviewer"]).map(
(persona) => persona.id,
),
["builtin:reviewer", "builtin:fizz"],
);
});
test("pickQuickBotPersonas falls back to any active personas when defaults are missing", () => {
const personas = [
createPersona("builtin:reviewer", "Reviewer"),
createPersona("custom:planner", "Planner"),
createPersona("custom:writer", "Writer"),
];
assert.deepEqual(
pickQuickBotPersonas(personas, []).map((persona) => persona.id),
["builtin:reviewer", "custom:planner", "custom:writer"],
);
});
test("pickQuickBotPersonas skips duplicate and missing recents", () => {
const personas = [
createPersona("builtin:fizz", "Fizz"),
createPersona("custom:honey", "Honey"),
];
assert.deepEqual(
pickQuickBotPersonas(personas, [
"builtin:fizz",
"missing",
"builtin:fizz",
"custom:honey",
]).map((persona) => persona.id),
["builtin:fizz", "custom:honey"],
);
});

View File

@@ -1,91 +0,0 @@
import * as React from "react";
import type { AgentPersona } from "@/shared/api/types";
const STORAGE_KEY = "buzz: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 = ["Fizz"] as const;
export function pickQuickBotPersonas(
personas: readonly AgentPersona[],
recentIds: readonly string[],
maxCount = 3,
) {
if (personas.length === 0) {
return [];
}
const resolved: AgentPersona[] = [];
const addPersona = (persona: AgentPersona | undefined) => {
if (!persona || resolved.some((candidate) => candidate.id === persona.id)) {
return;
}
resolved.push(persona);
};
for (const id of recentIds) {
if (resolved.length >= maxCount) {
break;
}
addPersona(personas.find((persona) => persona.id === id));
}
for (const name of DEFAULT_PERSONA_NAMES) {
if (resolved.length >= maxCount) {
break;
}
addPersona(
personas.find(
(persona) => persona.displayName.toLowerCase() === name.toLowerCase(),
),
);
}
for (const persona of personas) {
if (resolved.length >= maxCount) {
break;
}
addPersona(persona);
}
return resolved;
}
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 };
}

View File

@@ -1,25 +1,37 @@
import type { AgentDraft } from "@/features/agents/ui/agentDraft";
const OPEN_CREATE_AGENT_EVENT = "buzz:open-create-agent";
let pendingOpenCreateAgent = false;
type PendingOpenCreateAgent = {
draft: AgentDraft | null;
};
export function requestOpenCreateAgent() {
pendingOpenCreateAgent = true;
let pendingOpenCreateAgent: PendingOpenCreateAgent | null = null;
/**
* Ask the Agents view to open the create-agent flow. Without a draft it opens
* the wizard's start step; with a draft (e.g. "duplicate this agent" from a
* profile panel) it jumps straight to the prefilled details step. The request
* is retained until the Agents view mounts and consumes it.
*/
export function requestOpenCreateAgent(draft?: AgentDraft) {
pendingOpenCreateAgent = { draft: draft ?? null };
window.dispatchEvent(new Event(OPEN_CREATE_AGENT_EVENT));
}
export function consumePendingOpenCreateAgent() {
if (!pendingOpenCreateAgent) {
return false;
}
pendingOpenCreateAgent = false;
return true;
export function consumePendingOpenCreateAgent(): PendingOpenCreateAgent | null {
const pending = pendingOpenCreateAgent;
pendingOpenCreateAgent = null;
return pending;
}
export function subscribeOpenCreateAgent(handler: () => void) {
export function subscribeOpenCreateAgent(
handler: (pending: PendingOpenCreateAgent) => void,
) {
function handleOpenCreateAgent() {
pendingOpenCreateAgent = false;
handler();
const pending = pendingOpenCreateAgent ?? { draft: null };
pendingOpenCreateAgent = null;
handler(pending);
}
window.addEventListener(OPEN_CREATE_AGENT_EVENT, handleOpenCreateAgent);

View File

@@ -1,26 +1,27 @@
import { AlertTriangle } from "lucide-react";
import * as React from "react";
import { useAvailableAcpRuntimes } from "@/features/agents/hooks";
import {
useAvailableAcpRuntimes,
useCreateChannelManagedAgentsMutation,
} from "@/features/agents/hooks";
import type { CreateChannelManagedAgentsResult } from "@/features/agents/channelAgents";
import {
emptyResolvedTeamPersonas,
resolveTeamPersonas,
} from "@/features/agents/lib/teamPersonas";
attachManagedAgentToChannel,
createChannelManagedAgents,
type CreateChannelManagedAgentInput,
type CreateChannelManagedAgentsResult,
} from "@/features/agents/channelAgents";
import { resolveTeamMembers } from "@/features/agents/lib/teamMembers";
import {
collectRuntimeWarnings,
resolvePersonaRuntime,
} from "@/features/agents/lib/resolvePersonaRuntime";
import { useChannelsQuery } from "@/features/channels/hooks";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type {
AgentPersona,
AgentTeam,
Channel,
ChannelRole,
ManagedAgent,
} from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
@@ -34,6 +35,7 @@ import {
type AddTeamToChannelDialogProps = {
team: AgentTeam | null;
personas: AgentPersona[];
agents: ManagedAgent[];
open: boolean;
onOpenChange: (open: boolean) => void;
onDeployed: (
@@ -45,17 +47,64 @@ type AddTeamToChannelDialogProps = {
export function AddTeamToChannelDialog({
team,
personas,
agents,
open,
onOpenChange,
onDeployed,
}: AddTeamToChannelDialogProps) {
const channelsQuery = useChannelsQuery();
const providersQuery = useAvailableAcpRuntimes();
const queryClient = useQueryClient();
const [channelId, setChannelId] = React.useState("");
const [role, setRole] = React.useState<Exclude<ChannelRole, "owner">>("bot");
const deployMutation = useCreateChannelManagedAgentsMutation(
channelId || null,
);
const deployMutation = useMutation({
mutationFn: async (input: {
channelId: string;
personaInputs: CreateChannelManagedAgentInput[];
memberAgents: ManagedAgent[];
}): Promise<CreateChannelManagedAgentsResult> => {
// Existing agent members attach directly; pack persona members go
// through the persona create/reuse path.
const result =
input.personaInputs.length > 0
? await createChannelManagedAgents(
input.channelId,
input.personaInputs,
)
: { successes: [], failures: [] };
for (const agent of input.memberAgents) {
try {
const attached = await attachManagedAgentToChannel(input.channelId, {
agent,
role,
});
result.successes.push({
...attached,
created: false,
runtimeId: "",
});
} catch (error) {
result.failures.push({
kind: "generic",
name: agent.name,
personaId: null,
error:
error instanceof Error ? error.message : "Failed to add agent.",
});
}
}
return result;
},
onSettled: (_data, _error, variables) => {
void queryClient.invalidateQueries({ queryKey: ["managed-agents"] });
void queryClient.invalidateQueries({ queryKey: ["relay-agents"] });
void queryClient.invalidateQueries({
queryKey: ["channels", variables?.channelId, "members"],
});
},
});
const channels = React.useMemo(
() =>
@@ -68,20 +117,28 @@ export function AddTeamToChannelDialog({
const providers = providersQuery.data ?? [];
const defaultProvider = providers[0] ?? null;
const teamPersonaResolution = React.useMemo(
const resolution = React.useMemo(
() =>
team ? resolveTeamPersonas(team, personas) : emptyResolvedTeamPersonas(),
[team, personas],
team
? resolveTeamMembers(team, personas, agents)
: resolveTeamMembers(
{ personaIds: [], agentPubkeys: [] },
personas,
agents,
),
[team, personas, agents],
);
const resolved = teamPersonaResolution.resolvedPersonas;
const missingPersonaCount = teamPersonaResolution.missingPersonaCount;
const resolvedPersonas = resolution.resolvedPersonas;
const resolvedAgents = resolution.resolvedAgents;
const resolvedMembers = resolution.resolvedMembers;
const missingMemberCount = resolution.missingMemberCount;
// Surface warnings when a persona's preferred runtime is unavailable.
// Surface warnings when a pack persona's preferred runtime is unavailable.
// This dialog has no runtime selector, so the fallback is always
// `defaultProvider` (the first available runtime).
const runtimeWarnings = React.useMemo(
() => collectRuntimeWarnings(resolved, providers, defaultProvider),
[resolved, providers, defaultProvider],
() => collectRuntimeWarnings(resolvedPersonas, providers, defaultProvider),
[resolvedPersonas, providers, defaultProvider],
);
function reset() {
@@ -110,22 +167,28 @@ export function AddTeamToChannelDialog({
channels.find((channel) => channel.id === channelId) ?? null;
async function handleDeploy() {
if (!team || !selectedChannel || !defaultProvider) {
if (!team || !selectedChannel) {
return;
}
if (resolvedPersonas.length > 0 && !defaultProvider) {
return;
}
try {
// Resolve each persona's preferred runtime. This dialog has no
// runtime selector, so the fallback is `defaultProvider` (first
// available runtime). Warnings are computed separately via the
// `runtimeWarnings` memo and rendered as inline alerts above.
const inputs = resolved.map((persona) => {
// Pack persona members: resolve each persona's preferred runtime.
// This dialog has no runtime selector, so the fallback is
// `defaultProvider` (first available runtime). Warnings are computed
// separately via the `runtimeWarnings` memo above.
const personaInputs = resolvedPersonas.map((persona) => {
const { runtime: personaRuntime } = resolvePersonaRuntime(
persona.runtime,
providers,
defaultProvider,
);
const runtimeToUse = personaRuntime ?? defaultProvider;
if (!runtimeToUse) {
throw new Error("No available runtime found for this team.");
}
return {
runtime: {
id: runtimeToUse.id,
@@ -143,7 +206,11 @@ export function AddTeamToChannelDialog({
};
});
const result = await deployMutation.mutateAsync(inputs);
const result = await deployMutation.mutateAsync({
channelId: selectedChannel.id,
personaInputs,
memberAgents: resolvedAgents,
});
onDeployed(selectedChannel, result);
handleOpenChange(false);
} catch {
@@ -158,31 +225,30 @@ export function AddTeamToChannelDialog({
<DialogHeader className="shrink-0 border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>Deploy team to channel</DialogTitle>
<DialogDescription>
Create and attach one agent per persona in{" "}
<strong>{team?.name ?? "this team"}</strong> to the selected
channel.
Add every agent in <strong>{team?.name ?? "this team"}</strong> to
the selected channel.
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 py-5">
{resolved.length > 0 ? (
{resolvedMembers.length > 0 ? (
<div className="space-y-1.5">
<span className="text-sm font-medium">
Personas ({resolved.length})
Agents ({resolvedMembers.length})
</span>
<div className="flex flex-wrap gap-2">
{resolved.map((persona) => (
{resolvedMembers.map((member) => (
<div
className="flex items-center gap-1.5 rounded-full border border-border/70 bg-muted/30 px-2 py-1"
key={persona.id}
key={member.key}
>
<ProfileAvatar
avatarUrl={persona.avatarUrl}
avatarUrl={member.avatarUrl}
className="h-5 w-5 text-2xs"
label={persona.displayName}
label={member.displayName}
/>
<span className="text-xs font-medium">
{persona.displayName}
{member.displayName}
</span>
</div>
))}
@@ -235,16 +301,18 @@ export function AddTeamToChannelDialog({
</select>
</div>
{missingPersonaCount > 0 ? (
{missingMemberCount > 0 ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
This team references {missingPersonaCount} persona
{missingPersonaCount === 1 ? "" : "s"} that{" "}
{missingPersonaCount === 1 ? "is" : "are"} no longer in My
Agents. Add them back or edit the team before deploying.
This team references {missingMemberCount} agent
{missingMemberCount === 1 ? "" : "s"} that{" "}
{missingMemberCount === 1 ? "is" : "are"} no longer available on
this device. Edit the team before deploying.
</p>
) : null}
{!defaultProvider && !providersQuery.isLoading ? (
{resolvedPersonas.length > 0 &&
!defaultProvider &&
!providersQuery.isLoading ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
No ACP runtimes found. Make sure an agent runtime (e.g. Goose)
is installed.
@@ -289,9 +357,9 @@ export function AddTeamToChannelDialog({
disabled={
!team ||
!selectedChannel ||
!defaultProvider ||
resolved.length === 0 ||
missingPersonaCount > 0 ||
(resolvedPersonas.length > 0 && !defaultProvider) ||
resolvedMembers.length === 0 ||
missingMemberCount > 0 ||
channelsQuery.isLoading ||
providersQuery.isLoading ||
deployMutation.isPending
@@ -302,7 +370,7 @@ export function AddTeamToChannelDialog({
>
{deployMutation.isPending
? "Deploying..."
: `Deploy ${resolved.length} ${resolved.length === 1 ? "agent" : "agents"}`}
: `Deploy ${resolvedMembers.length} ${resolvedMembers.length === 1 ? "agent" : "agents"}`}
</Button>
</div>
</div>

View File

@@ -0,0 +1,85 @@
import {
CopyPlus,
Ellipsis,
FileDown,
MessageSquarePlus,
Pencil,
Trash2,
} from "lucide-react";
import type { ManagedAgent } from "@/shared/api/types";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
export function AgentActionsMenu({
agent,
disabled,
onAddToChannel,
onDelete,
onDuplicate,
onEdit,
onExport,
}: {
agent: ManagedAgent;
disabled: boolean;
onAddToChannel: (agent: ManagedAgent) => void;
onDelete: (agent: ManagedAgent) => void;
onDuplicate: (agent: ManagedAgent) => void;
onEdit: (agent: ManagedAgent) => void;
onExport: (agent: ManagedAgent) => void;
}) {
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
aria-label={`Open actions for ${agent.name}`}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
type="button"
>
<Ellipsis className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem disabled={disabled} onClick={() => onEdit(agent)}>
<Pencil className="h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
disabled={disabled}
onClick={() => onDuplicate(agent)}
>
<CopyPlus className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
disabled={disabled}
onClick={() => onAddToChannel(agent)}
>
<MessageSquarePlus className="h-4 w-4" />
Add to channel
</DropdownMenuItem>
<DropdownMenuItem disabled={disabled} onClick={() => onExport(agent)}>
<FileDown className="h-4 w-4" />
Export
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={disabled}
onClick={() => onDelete(agent)}
>
<Trash2 className="h-4 w-4" />
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -115,7 +115,7 @@ function provenanceSentence(
case "buzzExplicit":
return "Set in Buzz";
case "personaDefault":
return "Inherited from persona";
return "Inherited from agent profile";
case "runtimeOverride":
return "Live override (this session only)";
case "harnessConstraint":

View File

@@ -1,57 +0,0 @@
import type { ManagedAgent, PresenceLookup } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { ManagedAgentRow } from "./ManagedAgentRow";
export type AgentGroupRowsProps = {
agents: ManagedAgent[];
channelIdToName: Record<string, string>;
channelsByPubkey: Record<string, { id: string; name: string }[]>;
logContent: string | null;
logError: Error | null;
logLoading: boolean;
personaLabelsById: Record<string, string>;
presenceLoaded: boolean;
presenceLookup: PresenceLookup;
selectedLogAgentPubkey: string | null;
onOpenProfile: (pubkey: string) => void;
onSelectLogAgent: (pubkey: string | null) => void;
};
export function AgentGroupRows({
agents,
channelIdToName,
channelsByPubkey,
logContent,
logError,
logLoading,
personaLabelsById,
presenceLoaded,
presenceLookup,
selectedLogAgentPubkey,
onOpenProfile,
onSelectLogAgent,
}: AgentGroupRowsProps) {
return (
<div className="divide-y divide-border/50 border-t border-border/50">
{agents.map((agent) => (
<ManagedAgentRow
agent={agent}
channelIdToName={channelIdToName}
channelNames={channelsByPubkey[normalizePubkey(agent.pubkey)] ?? []}
isLogSelected={selectedLogAgentPubkey === agent.pubkey}
key={agent.pubkey}
logContent={
selectedLogAgentPubkey === agent.pubkey ? logContent : null
}
logError={selectedLogAgentPubkey === agent.pubkey ? logError : null}
logLoading={selectedLogAgentPubkey === agent.pubkey && logLoading}
personaLabelsById={personaLabelsById}
presenceLoaded={presenceLoaded}
presenceLookup={presenceLookup}
onOpenProfile={onOpenProfile}
onSelectLogAgent={onSelectLogAgent}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,342 @@
import * as React from "react";
import { AnimatePresence } from "motion/react";
import type { AcpRuntimeCatalogEntry } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Input } from "@/shared/ui/input";
import type { EnvVarsValue } from "./EnvVarsEditor";
import { PersonaDropdownField } from "./PersonaDropdownField";
import { PersonaModelField } from "./PersonaModelField";
import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField";
import {
AUTO_MODEL_DROPDOWN_VALUE,
AUTO_PROVIDER_DROPDOWN_VALUE,
CUSTOM_MODEL_DROPDOWN_VALUE,
CUSTOM_PROVIDER_DROPDOWN_VALUE,
getDefaultLlmProviderLabel,
getModelSelectValue,
getPersonaModelOptions,
getPersonaProviderOptions,
getProviderApiKeyConfig,
getProviderApiKeyEnvVar,
getRuntimePersonaModelOptions,
hasPersonaModelOption,
PERSONA_FIELD_CONTROL_CLASS,
PERSONA_FIELD_SHELL_CLASS,
PERSONA_LABEL_OPTIONAL_CLASS,
providerRequiresExplicitModel,
runtimeSupportsLlmProviderSelection,
shouldClearKnownModelForSelectionScope,
type PersonaDropdownOption,
} from "./personaDialogPickers";
import {
MODEL_DISCOVERY_LOADING_VALUE,
usePersonaModelDiscovery,
} from "./usePersonaModelDiscovery";
const FIELD_TRANSITION = { duration: 0.18, ease: [0.23, 1, 0.32, 1] } as const;
/**
* LLM provider + model pickers (with live model discovery) and the managed
* provider API-key field, for a given ACP runtime. Controlled: the parent
* owns `model`, `provider`, and `envVars`.
*/
export function AgentModelProviderFields({
disabled,
envVars,
model,
open,
provider,
runtimeId,
selectedRuntime,
onEnvVarsChange,
onModelChange,
onProviderChange,
}: {
disabled: boolean;
envVars: EnvVarsValue;
model: string;
open: boolean;
provider: string;
runtimeId: string;
selectedRuntime: AcpRuntimeCatalogEntry | undefined;
onEnvVarsChange: (next: EnvVarsValue) => void;
onModelChange: (next: string) => void;
onProviderChange: (next: string) => void;
}) {
const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false);
const [isCustomProviderEditing, setIsCustomProviderEditing] =
React.useState(false);
const trimmedRuntime = runtimeId.trim();
const llmProviderFieldVisible =
trimmedRuntime.length > 0 &&
runtimeSupportsLlmProviderSelection(trimmedRuntime);
const modelFieldVisible = trimmedRuntime.length > 0;
const providerForModelScope = llmProviderFieldVisible ? provider : "";
const trimmedProvider = provider.trim();
const providerApiKeyConfig =
llmProviderFieldVisible && !isCustomProviderEditing
? getProviderApiKeyConfig(trimmedProvider)
: null;
const providerApiKeyValue = providerApiKeyConfig
? (envVars[providerApiKeyConfig.envVar] ?? "")
: "";
const isExplicitModelRequired =
modelFieldVisible && providerRequiresExplicitModel(providerForModelScope);
const {
discoveredModelOptions,
modelDiscoveryLoading,
modelDiscoveryStatus,
} = usePersonaModelDiscovery({
envVars,
isCustomProviderEditing,
modelFieldVisible,
open,
provider: providerForModelScope,
selectedRuntime,
});
const staticModelOptions = getPersonaModelOptions(
trimmedRuntime,
providerForModelScope,
);
const runtimeModelOptions = getRuntimePersonaModelOptions(trimmedRuntime);
const modelOptions = discoveredModelOptions ?? staticModelOptions;
const isModelCustom = !hasPersonaModelOption(
discoveredModelOptions ?? runtimeModelOptions,
model,
);
const modelSelectValue = getModelSelectValue({
isCustomModelEditing,
isModelCustom,
model,
});
const showCustomModelInput =
modelFieldVisible && (isCustomModelEditing || isModelCustom);
const providerOptions = getPersonaProviderOptions(
providerForModelScope,
trimmedRuntime,
);
const providerSelectValue = isCustomProviderEditing
? CUSTOM_PROVIDER_DROPDOWN_VALUE
: trimmedProvider || AUTO_PROVIDER_DROPDOWN_VALUE;
const providerDropdownOptions: PersonaDropdownOption[] = [
...providerOptions.map((option) => ({
label: option.label,
value: option.id || AUTO_PROVIDER_DROPDOWN_VALUE,
})),
{ label: "Custom provider...", value: CUSTOM_PROVIDER_DROPDOWN_VALUE },
];
const modelDropdownOptions: PersonaDropdownOption[] = [
...modelOptions.map((option) => ({
label: option.label,
value: option.id || AUTO_MODEL_DROPDOWN_VALUE,
})),
...(modelDiscoveryLoading && discoveredModelOptions === null
? [
{
disabled: true,
label: "Loading models...",
value: MODEL_DISCOVERY_LOADING_VALUE,
},
]
: []),
{ label: "Custom model...", value: CUSTOM_MODEL_DROPDOWN_VALUE },
];
React.useEffect(() => {
if (
!open ||
!modelFieldVisible ||
isCustomModelEditing ||
!shouldClearKnownModelForSelectionScope({
model,
provider: providerForModelScope,
runtime: trimmedRuntime,
})
) {
return;
}
onModelChange("");
setIsCustomModelEditing(false);
}, [
isCustomModelEditing,
model,
modelFieldVisible,
onModelChange,
open,
providerForModelScope,
trimmedRuntime,
]);
// Reset custom-editing state and clear a provider that the new runtime
// can't choose whenever the runtime changes underneath us.
const previousRuntimeRef = React.useRef(trimmedRuntime);
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally runs only on runtime change
React.useEffect(() => {
if (previousRuntimeRef.current === trimmedRuntime) {
return;
}
previousRuntimeRef.current = trimmedRuntime;
if (!llmProviderFieldVisible && trimmedProvider.length > 0) {
clearManagedProviderApiKeyWhenLeaving(trimmedProvider, "");
setIsCustomProviderEditing(false);
onProviderChange("");
}
setIsCustomModelEditing(false);
}, [trimmedRuntime]);
function updateProviderApiKey(envKey: string, value: string) {
const current = envVars[envKey] ?? "";
if (current === value) {
return;
}
const next = { ...envVars };
if (value.length > 0) {
next[envKey] = value;
} else {
delete next[envKey];
}
onEnvVarsChange(next);
}
function clearManagedProviderApiKeyWhenLeaving(
previousProvider: string,
nextProvider: string,
) {
const previousEnvVar = getProviderApiKeyEnvVar(previousProvider);
const nextEnvVar = getProviderApiKeyEnvVar(nextProvider);
if (previousEnvVar && previousEnvVar !== nextEnvVar) {
if (previousEnvVar in envVars) {
const next = { ...envVars };
delete next[previousEnvVar];
onEnvVarsChange(next);
}
}
}
function handleProviderDropdownChange(nextValue: string) {
if (nextValue === CUSTOM_PROVIDER_DROPDOWN_VALUE) {
clearManagedProviderApiKeyWhenLeaving(provider, "");
setIsCustomProviderEditing(true);
onProviderChange("");
return;
}
const nextProvider =
nextValue === AUTO_PROVIDER_DROPDOWN_VALUE ? "" : nextValue;
clearManagedProviderApiKeyWhenLeaving(provider, nextProvider);
setIsCustomProviderEditing(false);
onProviderChange(nextProvider);
const requiredEnvVar = getProviderApiKeyEnvVar(nextProvider);
if (requiredEnvVar && !envVars[requiredEnvVar]?.trim()) {
onModelChange("");
setIsCustomModelEditing(false);
return;
}
if (
!isCustomModelEditing &&
shouldClearKnownModelForSelectionScope({
model,
provider: nextProvider,
runtime: trimmedRuntime,
})
) {
onModelChange("");
setIsCustomModelEditing(false);
}
}
function handleModelDropdownChange(nextValue: string) {
if (nextValue === CUSTOM_MODEL_DROPDOWN_VALUE) {
setIsCustomModelEditing(true);
if (!isModelCustom) {
onModelChange("");
}
return;
}
setIsCustomModelEditing(false);
onModelChange(nextValue === AUTO_MODEL_DROPDOWN_VALUE ? "" : nextValue);
}
if (!modelFieldVisible && !llmProviderFieldVisible) {
return null;
}
return (
<>
{llmProviderFieldVisible ? (
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="agent-llm-provider"
>
LLM provider
<span className={PERSONA_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<PersonaDropdownField
disabled={disabled}
id="agent-llm-provider"
onValueChange={handleProviderDropdownChange}
options={providerDropdownOptions}
placeholder={getDefaultLlmProviderLabel(trimmedRuntime)}
value={providerSelectValue}
/>
{llmProviderFieldVisible && isCustomProviderEditing ? (
<div
className={cn(
"mt-2 flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
aria-label="Custom provider ID"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={disabled}
id="agent-custom-provider"
onChange={(event) => onProviderChange(event.target.value)}
placeholder="Custom provider ID"
value={provider}
/>
</div>
) : null}
{providerApiKeyConfig ? (
<PersonaProviderApiKeyField
config={providerApiKeyConfig}
disabled={disabled}
onChange={(value) =>
updateProviderApiKey(providerApiKeyConfig.envVar, value)
}
value={providerApiKeyValue}
/>
) : null}
</div>
) : null}
<AnimatePresence initial={false}>
{modelFieldVisible ? (
<PersonaModelField
disabled={disabled}
isExplicitModelRequired={isExplicitModelRequired}
model={model}
modelDiscoveryStatus={modelDiscoveryStatus}
modelDropdownOptions={modelDropdownOptions}
modelSelectValue={modelSelectValue}
onCustomModelChange={onModelChange}
onModelValueChange={handleModelDropdownChange}
showCustomModelInput={showCustomModelInput}
transition={FIELD_TRANSITION}
/>
) : null}
</AnimatePresence>
</>
);
}

View File

@@ -1,7 +1,6 @@
import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { usePersonasQuery } from "@/features/agents/hooks";
import { useOpenDmMutation } from "@/features/channels/hooks";
import {
type ProfilePanelTab,
@@ -13,7 +12,6 @@ import {
profilePanelViewFromSearch,
} from "@/features/profile/ui/UserProfilePanelUtils";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { AgentPersona } from "@/shared/api/types";
import {
type ProfilePanelOpenOptions,
ProfilePanelProvider,
@@ -27,41 +25,20 @@ const AgentsView = React.lazy(async () => {
return { default: module.AgentsView };
});
type ProfilePanelTarget =
| { kind: "pubkey"; pubkey: string }
| { kind: "persona"; persona: AgentPersona };
const AGENTS_PROFILE_SEARCH_KEYS = [
"profile",
"profilePersona",
"profileTab",
"profileView",
] as const;
export function AgentsScreen() {
const identityQuery = useIdentityQuery();
const personasQuery = usePersonasQuery();
const { applyPatch, values } = useHistorySearchState(
AGENTS_PROFILE_SEARCH_KEYS,
);
const profilePanelTab = profilePanelTabFromSearch(values.profileTab);
const profilePanelView = profilePanelViewFromSearch(values.profileView);
const profilePanelTarget = React.useMemo<ProfilePanelTarget | null>(() => {
if (values.profile) {
return { kind: "pubkey", pubkey: values.profile };
}
if (values.profilePersona) {
const persona = personasQuery.data?.find(
(candidate) => candidate.id === values.profilePersona,
);
if (persona) {
return { kind: "persona", persona };
}
}
return null;
}, [personasQuery.data, values.profile, values.profilePersona]);
const profilePubkey = values.profile || null;
const threadPanelWidth = useThreadPanelWidth();
const openDmMutation = useOpenDmMutation();
const { goChannel } = useAppNavigation();
@@ -70,7 +47,6 @@ export function AgentsScreen() {
(pubkey: string, options?: ProfilePanelOpenOptions) => {
applyPatch({
profile: pubkey,
profilePersona: null,
profileTab: options?.tab === "info" ? null : (options?.tab ?? null),
profileView: null,
});
@@ -78,21 +54,9 @@ export function AgentsScreen() {
[applyPatch],
);
const handleOpenPersonaProfilePanel = React.useCallback(
(persona: AgentPersona) => {
applyPatch({
profile: null,
profilePersona: persona.id,
profileTab: null,
profileView: null,
});
},
[applyPatch],
);
const handleCloseProfilePanel = React.useCallback(() => {
applyPatch({
profile: null,
profilePersona: null,
profileTab: null,
profileView: null,
});
@@ -117,16 +81,13 @@ export function AgentsScreen() {
);
return (
<ProfilePanelProvider
onOpenPersonaProfilePanel={handleOpenPersonaProfilePanel}
onOpenProfilePanel={handleOpenProfilePanel}
>
<ProfilePanelProvider onOpenProfilePanel={handleOpenProfilePanel}>
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
<React.Suspense fallback={<ViewLoadingFallback kind="agents" />}>
<AgentsView />
</React.Suspense>
{profilePanelTarget ? (
{profilePubkey ? (
<UserProfilePanel
canResetWidth={threadPanelWidth.canReset}
currentPubkey={identityQuery.data?.pubkey}
@@ -137,16 +98,7 @@ export function AgentsScreen() {
onResizeStart={threadPanelWidth.onResizeStart}
onTabChange={handleProfilePanelTabChange}
onViewChange={handleProfilePanelViewChange}
persona={
profilePanelTarget.kind === "persona"
? profilePanelTarget.persona
: undefined
}
pubkey={
profilePanelTarget.kind === "pubkey"
? profilePanelTarget.pubkey
: undefined
}
pubkey={profilePubkey}
tab={profilePanelTab}
view={profilePanelView}
widthPx={threadPanelWidth.widthPx}

View File

@@ -3,15 +3,16 @@ import {
consumePendingOpenCreateAgent,
subscribeOpenCreateAgent,
} from "@/features/agents/openCreateAgentEvent";
import { usePersonasQuery } from "@/features/agents/hooks";
import type { ManagedAgent } from "@/shared/api/types";
import { useProfilePanel } from "@/shared/context/ProfilePanelContext";
import type { AgentDraft } from "./agentDraft";
import { AddAgentToChannelDialog } from "./AddAgentToChannelDialog";
import { AddTeamToChannelDialog } from "./AddTeamToChannelDialog";
import { BatchImportDialog } from "./BatchImportDialog";
import { CreateAgentDialog } from "./CreateAgentDialog";
import { PersonaCatalogDialog } from "./PersonaCatalogDialog";
import { PersonaDialog } from "./PersonaDialog";
import { PersonaDeleteDialog } from "./PersonaDeleteDialog";
import { PersonaImportUpdateDialog } from "./PersonaImportUpdateDialog";
import { PersonaShareDialog } from "./PersonaShareDialog";
import { CreateAgentStartDialog } from "./CreateAgentStartDialog";
import { EditAgentDialog } from "./EditAgentDialog";
import { RelayDirectorySection } from "./RelayDirectorySection";
import { SecretRevealDialog } from "./SecretRevealDialog";
import { TeamDeleteDialog } from "./TeamDeleteDialog";
@@ -21,14 +22,15 @@ import { TeamImportUpdateDialog } from "./TeamImportUpdateDialog";
import { TeamsSection } from "./TeamsSection";
import { UnifiedAgentsSection } from "./UnifiedAgentsSection";
import { useManagedAgentActions } from "./useManagedAgentActions";
import { usePersonaActions } from "./usePersonaActions";
import { useTeamActions } from "./useTeamActions";
import { useProfilePanel } from "@/shared/context/ProfilePanelContext";
export function AgentsView() {
const { openPersonaProfilePanel, openProfilePanel } = useProfilePanel();
const { openProfilePanel } = useProfilePanel();
const agents = useManagedAgentActions();
const personas = usePersonaActions();
const personasQuery = usePersonasQuery();
const [agentToEdit, setAgentToEdit] = React.useState<ManagedAgent | null>(
null,
);
const teamActions = useTeamActions(
{
setActionNoticeMessage: agents.setActionNoticeMessage,
@@ -40,23 +42,32 @@ export function AgentsView() {
},
);
const personas = personasQuery.data ?? [];
const isActionPending =
agents.isPending ||
personas.isPending ||
teamActions.exportTeamJsonMutation.isPending ||
teamActions.createTeamMutation.isPending ||
teamActions.updateTeamMutation.isPending ||
teamActions.deleteTeamMutation.isPending;
// biome-ignore lint/correctness/useExhaustiveDependencies: subscribe once on mount
React.useEffect(() => {
if (consumePendingOpenCreateAgent()) {
agents.setIsCreateOpen(true);
function handleRequest(pending: { draft: AgentDraft | null }) {
if (pending.draft) {
agents.setIsCreateStartOpen(false);
agents.setCreateDraft(pending.draft);
} else {
agents.openCreateStart();
}
}
return subscribeOpenCreateAgent(() => {
agents.setIsCreateOpen(true);
});
}, [agents.setIsCreateOpen]);
const pending = consumePendingOpenCreateAgent();
if (pending) {
handleRequest(pending);
}
return subscribeOpenCreateAgent(handleRequest);
}, []);
return (
<>
@@ -75,63 +86,33 @@ export function AgentsView() {
isActionPending={isActionPending}
isAgentsLoading={agents.managedAgentsQuery.isLoading}
startingAgentPubkey={agents.startingAgentPubkey}
startingPersonaIds={agents.startingPersonaIds}
onBulkRemoveStopped={() => {
void agents.handleBulkRemoveStopped();
}}
onBulkStopRunning={() => {
void agents.handleBulkStopRunning();
}}
onCreateAgent={() => {
agents.setIsCreateOpen(true);
}}
onCreateAgent={agents.openCreateStart}
onOpenAgentProfile={(pubkey, options) => {
openProfilePanel?.(pubkey, options);
}}
onOpenPersonaProfile={(persona) => {
openPersonaProfilePanel?.(persona);
}}
onStartAgent={(pubkey) => {
void agents.handleStart(pubkey);
}}
onStartPersona={(persona) => {
void agents.handleStartPersona(persona);
onAddAgentToChannel={agents.setAgentToAddToChannel}
onDeleteAgent={(agent) => {
void agents.handleDelete(agent.pubkey);
}}
// Persona props
canChooseCatalog={personas.catalogPersonas.length > 0}
personas={personas.libraryPersonas}
personasError={
personas.personasQuery.error instanceof Error
? personas.personasQuery.error
: null
}
personaFeedbackErrorMessage={
personas.personaFeedbackSurface === "library"
? personas.personaErrorMessage
: null
}
personaFeedbackNoticeMessage={
personas.personaFeedbackSurface === "library"
? personas.personaNoticeMessage
: null
}
isPersonasLoading={personas.personasQuery.isLoading}
isPersonasPending={personas.isPending}
onCreatePersona={personas.openCreate}
onChooseCatalog={personas.openCatalog}
onDuplicatePersona={personas.openDuplicate}
onEditPersona={personas.openEdit}
onSharePersona={personas.openShare}
onDeactivatePersona={(persona) => {
void personas.handleSetActive(persona, false, "library");
}}
onDeletePersona={personas.openDelete}
onImportPersonaFile={(fileBytes, fileName) => {
void personas.handleImportFile(fileBytes, fileName);
onDuplicateAgent={agents.openDuplicateAgent}
onEditAgent={setAgentToEdit}
onExportAgent={agents.handleExportAgent}
onImportAgentFile={(fileBytes, fileName) => {
void agents.handleImportAgentFile(fileBytes, fileName);
}}
/>
<TeamsSection
agents={agents.managedAgents}
error={
teamActions.teamsQuery.error instanceof Error
? teamActions.teamsQuery.error
@@ -153,7 +134,7 @@ export function AgentsView() {
onSync={teamActions.handleSyncTeam}
onRevealInFinder={teamActions.handleRevealInFinder}
onAddToChannel={teamActions.setTeamToAddToChannel}
personas={personas.libraryPersonas}
personas={personas}
teams={teamActions.teams}
/>
@@ -171,14 +152,39 @@ export function AgentsView() {
</div>
</div>
{agents.isCreateOpen ? (
<CreateAgentStartDialog
onImportFile={(fileBytes, fileName) => {
void agents.handleImportAgentFile(fileBytes, fileName);
}}
onOpenChange={agents.setIsCreateStartOpen}
onPickBlank={agents.handlePickBlank}
onPickTemplate={agents.handlePickTemplate}
open={agents.isCreateStartOpen}
/>
{agents.createDraft ? (
<CreateAgentDialog
draft={agents.createDraft}
onCreated={(result) => {
agents.setLogAgentPubkey(result.agent.pubkey);
agents.setCreatedAgent(result);
}}
onOpenChange={agents.setIsCreateOpen}
open={agents.isCreateOpen}
onOpenChange={(open) => {
if (!open) {
agents.setCreateDraft(null);
}
}}
open={agents.createDraft !== null}
/>
) : null}
{agentToEdit ? (
<EditAgentDialog
agent={agentToEdit}
onOpenChange={(open) => {
if (!open) {
setAgentToEdit(null);
}
}}
open={agentToEdit !== null}
/>
) : null}
{agents.agentToAddToChannel ? (
@@ -203,122 +209,22 @@ export function AgentsView() {
}}
/>
) : null}
{personas.createdAgent ? (
<SecretRevealDialog
created={personas.createdAgent}
{agents.batchImportResult ? (
<BatchImportDialog
fileName={agents.batchImportFileName}
onComplete={agents.handleBatchImportComplete}
onOpenChange={(open) => {
if (!open) {
personas.setCreatedAgent(null);
agents.setBatchImportResult(null);
}
}}
/>
) : null}
{personas.personaDialogState ? (
<PersonaDialog
description={personas.personaDialogState.description}
error={
personas.updatePersonaMutation.error instanceof Error
? personas.updatePersonaMutation.error
: personas.createPersonaMutation.error instanceof Error
? personas.createPersonaMutation.error
: null
}
initialValues={personas.personaDialogState.initialValues}
isImportPending={
personas.personaImportActions.isApplyingPersonaImportUpdate
}
isPending={personas.isPending}
runtimes={personas.acpRuntimesQuery.data ?? []}
runtimesLoading={personas.acpRuntimesQuery.isLoading}
onImportUpdateFile={
personas.personaImportActions.handleEditDialogImportUpdateFile
}
onOpenChange={(open) => {
if (!open) {
personas.setPersonaDialogState(null);
}
}}
onSubmit={personas.handleSubmit}
open={personas.personaDialogState !== null}
submitLabel={personas.personaDialogState.submitLabel}
title={personas.personaDialogState.title}
/>
) : null}
{personas.personaToDelete ? (
<PersonaDeleteDialog
onConfirm={(persona) => {
void personas.handleDelete(persona);
}}
onOpenChange={(open) => {
if (!open) {
personas.setPersonaToDelete(null);
}
}}
open={personas.personaToDelete !== null}
persona={personas.personaToDelete}
/>
) : null}
{personas.personaToShare ? (
<PersonaShareDialog
isCatalogVisible={
personas.personaToShare.isBuiltIn ||
personas.sharedCatalogPersonaIdSet.has(personas.personaToShare.id)
}
isPending={personas.isPending}
onCatalogVisibilityChange={(visible) => {
if (personas.personaToShare) {
personas.setPersonaCatalogVisibility(
personas.personaToShare,
visible,
);
}
}}
onExport={() => {
if (personas.personaToShare) {
personas.handleExport(personas.personaToShare);
}
}}
onOpenChange={(open) => {
if (!open) {
personas.setPersonaToShare(null);
}
}}
open={personas.personaToShare !== null}
persona={personas.personaToShare}
/>
) : null}
{personas.isCatalogDialogOpen ? (
<PersonaCatalogDialog
error={
personas.personasQuery.error instanceof Error
? personas.personasQuery.error
: null
}
feedbackErrorMessage={
personas.personaFeedbackSurface === "catalog"
? personas.personaErrorMessage
: null
}
feedbackNoticeMessage={
personas.personaFeedbackSurface === "catalog"
? personas.personaNoticeMessage
: null
}
isLoading={personas.personasQuery.isLoading}
isPending={personas.setPersonaActiveMutation.isPending}
onClearFeedback={() => {
personas.clearFeedback("catalog");
}}
onOpenChange={personas.setIsCatalogDialogOpen}
onSelectPersona={(persona, active) => {
void personas.handleSetActive(persona, active, "catalog");
}}
open={personas.isCatalogDialogOpen}
personas={personas.catalogPersonas}
open={agents.batchImportResult !== null}
result={agents.batchImportResult}
/>
) : null}
{teamActions.teamDialogState ? (
<TeamDialog
agents={agents.managedAgents}
description={teamActions.teamDialogState.description}
error={
teamActions.updateTeamMutation.error instanceof Error
@@ -339,10 +245,10 @@ export function AgentsView() {
teamActions.setTeamDialogState(null);
}
}}
onDeleteRemovedPersonas={teamActions.handleDeleteRemovedPersonas}
onDeleteRemovedMembers={teamActions.handleDeleteRemovedMembers}
onSubmit={teamActions.handleTeamSubmit}
open={teamActions.teamDialogState !== null}
personas={personas.libraryPersonas}
personas={personas}
submitLabel={teamActions.teamDialogState.submitLabel}
title={teamActions.teamDialogState.title}
/>
@@ -363,6 +269,7 @@ export function AgentsView() {
) : null}
{teamActions.teamToAddToChannel ? (
<AddTeamToChannelDialog
agents={agents.managedAgents}
onDeployed={teamActions.handleTeamDeployed}
onOpenChange={(open) => {
if (!open) {
@@ -370,23 +277,10 @@ export function AgentsView() {
}
}}
open={teamActions.teamToAddToChannel !== null}
personas={personas.libraryPersonas}
personas={personas}
team={teamActions.teamToAddToChannel}
/>
) : null}
{personas.batchImportResult ? (
<BatchImportDialog
fileName={personas.batchImportFileName}
onComplete={personas.handleBatchImportComplete}
onOpenChange={(open) => {
if (!open) {
personas.setBatchImportResult(null);
}
}}
open={personas.batchImportResult !== null}
result={personas.batchImportResult}
/>
) : null}
{teamActions.teamImportPreview ? (
<TeamImportDialog
fileName={teamActions.teamImportPreview.fileName}
@@ -402,6 +296,7 @@ export function AgentsView() {
) : null}
{teamActions.teamImportTarget ? (
<TeamImportUpdateDialog
agents={agents.managedAgents}
fileName={teamActions.teamImportTargetPreview?.fileName ?? ""}
isPending={
teamActions.isApplyingTeamImportUpdate ||
@@ -415,38 +310,11 @@ export function AgentsView() {
}
}}
open={teamActions.teamImportTarget !== null}
personas={personas.libraryPersonas}
personas={personas}
preview={teamActions.teamImportTargetPreview?.preview ?? null}
team={teamActions.teamImportTarget}
/>
) : null}
{personas.personaImportActions.personaImportTarget ? (
<PersonaImportUpdateDialog
fileName={
personas.personaImportActions.personaImportTargetPreview
?.fileName ?? ""
}
isPending={
personas.personaImportActions.isApplyingPersonaImportUpdate ||
personas.updatePersonaMutation.isPending
}
onApply={personas.personaImportActions.handleImportUpdateApply}
onClear={
personas.personaImportActions.clearImportUpdateAndReturnToEdit
}
onOpenChange={(open) => {
if (!open) {
personas.personaImportActions.closeImportUpdateDialog();
}
}}
open={personas.personaImportActions.personaImportTarget !== null}
persona={personas.personaImportActions.personaImportTarget}
preview={
personas.personaImportActions.personaImportTargetPreview?.preview ??
null
}
/>
) : null}
</>
);
}

View File

@@ -7,8 +7,10 @@ import {
} from "@/shared/ui/import-status-icon";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { useAvailableAcpRuntimes } from "@/features/agents/hooks";
import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
import { createManagedAgent } from "@/shared/api/tauri";
import type { ParsePersonaFilesResult } from "@/shared/api/tauriPersonas";
import { createPersona } from "@/shared/api/tauriPersonas";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import {
@@ -18,9 +20,9 @@ import {
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { buildBatchImportPersonaInput } from "./batchImportPersonaInput";
import { importedAvatarUrl } from "./agentDraft";
import { buildBatchImportAgentInput } from "./batchImportPersonaInput";
import { resolveManagedAgentAvatarUrl } from "./managedAgentAvatar";
import { importedAvatarUrl } from "./personaDialogState";
type BatchImportDialogProps = {
fileName: string;
@@ -37,6 +39,7 @@ export function BatchImportDialog({
onOpenChange,
onComplete,
}: BatchImportDialogProps) {
const runtimesQuery = useAvailableAcpRuntimes({ enabled: open });
const [selected, setSelected] = React.useState<Set<number>>(new Set());
const [status, setStatus] = React.useState<
"idle" | "importing" | "done" | "error"
@@ -69,6 +72,16 @@ export function BatchImportDialog({
return;
}
const runtimes = runtimesQuery.data ?? [];
const defaultRuntime = runtimes[0] ?? null;
if (!defaultRuntime) {
setStatus("error");
setErrorMessage(
"No available agent runtime found. Visit Settings > Doctor to set one up.",
);
return;
}
setStatus("importing");
setErrorMessage(null);
@@ -82,8 +95,8 @@ export function BatchImportDialog({
let completed = 0;
for (const index of selected) {
const persona = result.personas[index];
if (!persona) {
const preview = result.personas[index];
if (!preview) {
continue;
}
@@ -94,9 +107,34 @@ export function BatchImportDialog({
});
try {
const input = buildBatchImportPersonaInput(persona);
const avatarUrl = await resolveManagedAgentAvatarUrl(input.avatarUrl);
await createPersona({ ...input, avatarUrl });
const input = buildBatchImportAgentInput(preview);
const resolved = resolvePersonaRuntime(
input.runtime ?? null,
runtimes,
defaultRuntime,
);
const runtime = resolved.runtime ?? defaultRuntime;
const avatarUrl = await resolveManagedAgentAvatarUrl(
input.avatarUrl,
undefined,
runtime.avatarUrl,
);
// Imported agents are created stopped so a large batch never spawns
// a fleet of processes at once.
await createManagedAgent({
name: input.name,
acpCommand: "buzz-acp",
agentCommand: runtime.command,
agentArgs: runtime.defaultArgs,
mcpCommand: runtime.mcpCommand ?? "",
systemPrompt: input.systemPrompt || undefined,
avatarUrl: avatarUrl || undefined,
model: input.model,
provider: input.provider,
spawnAfterCreate: false,
startOnAppLaunch: false,
backend: { type: "local" },
});
completed += 1;
setImportedCount(completed);
setItemStatuses((prev) => {
@@ -112,7 +150,7 @@ export function BatchImportDialog({
});
setStatus("error");
setErrorMessage(
`Imported ${completed} of ${selected.size}. Failed on '${persona.displayName}': ${error instanceof Error ? error.message : String(error)}. Already-imported personas are saved.`,
`Imported ${completed} of ${selected.size}. Failed on '${preview.displayName}': ${error instanceof Error ? error.message : String(error)}. Already-imported agents are saved.`,
);
return;
}
@@ -131,9 +169,9 @@ export function BatchImportDialog({
<DialogContent className="max-w-2xl overflow-hidden p-0">
<div className="flex max-h-[85vh] flex-col">
<DialogHeader className="shrink-0 border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>Import Personas</DialogTitle>
<DialogTitle>Import agents</DialogTitle>
<DialogDescription>
Found {personas.length} persona
Found {personas.length} agent
{personas.length !== 1 ? "s" : ""} in {fileName || "archive"}.
</DialogDescription>
</DialogHeader>

View File

@@ -23,6 +23,10 @@ import {
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Textarea } from "@/shared/ui/textarea";
import type { AgentDraft } from "./agentDraft";
import { AgentCreationPreview } from "./AgentCreationPreview";
import { AgentModelProviderFields } from "./AgentModelProviderFields";
import {
CreateAgentBasicsFields,
CreateAgentOptionToggles,
@@ -30,6 +34,7 @@ import {
CreateAgentRuntimeFields,
} from "./CreateAgentDialogSections";
import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor";
import { providerRequiresExplicitModel } from "./personaDialogPickers";
import {
coerceConfigValues,
ProviderConfigFields,
@@ -42,10 +47,13 @@ import { useLastRuntime } from "@/features/agents/lib/useLastRuntime";
export function CreateAgentDialog({
open,
draft,
onCreated,
onOpenChange,
}: {
open: boolean;
/** Prefilled starting point (blank / template / import / duplicate). */
draft: AgentDraft | null;
onCreated: (result: CreateManagedAgentResponse) => void;
onOpenChange: (open: boolean) => void;
}) {
@@ -69,11 +77,17 @@ export function CreateAgentDialog({
const [turnTimeoutSeconds, setTurnTimeoutSeconds] = React.useState("320");
const [parallelism, setParallelism] = React.useState("24");
const [systemPrompt, setSystemPrompt] = React.useState("");
const [avatarUrl, setAvatarUrl] = React.useState("");
const [isAvatarUploadPending, setIsAvatarUploadPending] =
React.useState(false);
const [model, setModel] = React.useState("");
const [llmProvider, setLlmProvider] = React.useState("");
const [envVars, setEnvVars] = React.useState<EnvVarsValue>({});
const [selectedRuntimeId, setSelectedRuntimeId] =
React.useState<string>("custom");
const [hasSyncedProviderSelection, setHasSyncedProviderSelection] =
React.useState(false);
const [hasAppliedDraft, setHasAppliedDraft] = React.useState(false);
const [showAdvanced, setShowAdvanced] = React.useState(false);
const [respondTo, setRespondTo] = React.useState<RespondToMode>("owner-only");
const [respondToAllowlist, setRespondToAllowlist] = React.useState<string[]>(
@@ -131,15 +145,39 @@ export function CreateAgentDialog({
prereqsQuery.isLoading || (prereqs !== null && !isSpawnSupported);
const isDiscoveryPending = providersQuery.isLoading || prereqsQuery.isLoading;
// Apply the draft's simple fields once per open. Runtime selection happens
// separately below because it needs the runtime catalog to resolve.
React.useEffect(() => {
if (!open || !draft || hasAppliedDraft) {
return;
}
setName(draft.name);
setSystemPrompt(draft.systemPrompt);
setAvatarUrl(draft.avatarUrl);
setModel(draft.model ?? "");
setLlmProvider(draft.provider ?? "");
setEnvVars({ ...draft.envVars });
setHasAppliedDraft(true);
}, [draft, hasAppliedDraft, open]);
React.useEffect(() => {
if (hasSyncedProviderSelection || providersQuery.isLoading) {
return;
}
// Prefer last-used runtime from localStorage
const remembered = lastRuntimeId
? runtimes.find((runtime) => runtime.id === lastRuntimeId)
// Prefer the draft's runtime hint (an id like "goose" or a resolved
// command for duplicated agents), then the last-used runtime.
const draftRuntime = draft?.runtime
? (runtimes.find((runtime) => runtime.id === draft.runtime) ??
runtimes.find((runtime) => runtime.command === draft.runtime) ??
null)
: null;
const remembered =
draftRuntime ??
(lastRuntimeId
? runtimes.find((runtime) => runtime.id === lastRuntimeId)
: null);
if (remembered) {
setSelectedRuntimeId(remembered.id);
setAgentCommand(remembered.command);
@@ -155,6 +193,7 @@ export function CreateAgentDialog({
setHasSyncedProviderSelection(true);
}, [
agentCommand,
draft,
hasSyncedProviderSelection,
lastRuntimeId,
runtimes,
@@ -241,9 +280,14 @@ export function CreateAgentDialog({
setTurnTimeoutSeconds("320");
setParallelism("24");
setSystemPrompt("");
setAvatarUrl("");
setIsAvatarUploadPending(false);
setModel("");
setLlmProvider("");
setEnvVars({});
setSelectedRuntimeId("custom");
setHasSyncedProviderSelection(false);
setHasAppliedDraft(false);
setShowAdvanced(false);
setRunOn("local");
setProviderConfig({});
@@ -309,6 +353,15 @@ export function CreateAgentDialog({
const respondToValid =
respondTo !== "allowlist" || respondToAllowlist.length > 0;
const showModelProviderFields =
!isProviderMode && !useMesh && selectedRuntimeId !== "custom";
// Anthropic/OpenAI-style providers have no server-side default model — an
// empty model would crash the harness at startup.
const modelRequirementSatisfied =
!showModelProviderFields ||
!providerRequiresExplicitModel(llmProvider) ||
model.trim().length > 0;
const canSubmit =
name.trim().length > 0 &&
!isDiscoveryPending &&
@@ -325,6 +378,8 @@ export function CreateAgentDialog({
// Relay-mesh mode requires a concrete serve target, not just a model name.
!(useMesh && (meshModelId.trim().length === 0 || meshTarget == null)) &&
respondToValid &&
modelRequirementSatisfied &&
!isAvatarUploadPending &&
!meshPreparing &&
!createMutation.isPending;
@@ -373,6 +428,9 @@ export function CreateAgentDialog({
? Number.parseInt(parallelism, 10)
: undefined,
systemPrompt: systemPrompt.trim() || undefined,
avatarUrl: avatarUrl.trim() || undefined,
model: model.trim() || undefined,
provider: llmProvider.trim() || undefined,
envVars,
spawnAfterCreate: true,
startOnAppLaunch: false, // Remote agents don't auto-start with the desktop
@@ -406,8 +464,12 @@ export function CreateAgentDialog({
? Number.parseInt(parallelism, 10)
: undefined,
systemPrompt: systemPrompt.trim() || undefined,
avatarUrl: avatarUrl.trim() || undefined,
envVars,
model: useMesh ? meshModelId.trim() || undefined : undefined,
model: useMesh
? meshModelId.trim() || undefined
: model.trim() || undefined,
provider: useMesh ? undefined : llmProvider.trim() || undefined,
relayMesh: useMesh ? { modelRef: meshModelId.trim() } : undefined,
spawnAfterCreate,
// Relay-mesh agents need a freshly selected serve target to start;
@@ -426,237 +488,291 @@ export function CreateAgentDialog({
}
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<Dialog
onOpenChange={(nextOpen) => {
if (!nextOpen && (createMutation.isPending || isAvatarUploadPending)) {
return;
}
handleOpenChange(nextOpen);
}}
open={open}
>
<DialogContent className="max-w-3xl overflow-hidden p-0">
<div className="flex max-h-[85vh] flex-col">
<DialogHeader className="shrink-0 border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>Create agent</DialogTitle>
<DialogTitle>{draft?.title ?? "Create agent"}</DialogTitle>
<DialogDescription>
This creates a local agent identity, syncs its display name when
possible, and can spawn `buzz-acp` immediately.
{draft?.description ??
"Set up a new agent and start it in this workspace."}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 py-5">
<CreateAgentBasicsFields name={name} onNameChange={setName} />
<RelayMeshAgentSection
current={{
acpCommand,
agentCommand,
agentArgs: agentArgs
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0),
mcpCommand,
model: meshModelId || null,
envVars,
}}
modelId={meshModelId}
targetEndpointAddr={meshTarget?.endpointAddr ?? ""}
onModelIdChange={(nextId, patch) => {
setMeshModelId(nextId);
if (patch == null) return;
// Fan out the preset into the existing setters so the rest
// of the dialog (and the submit branch) see normal local-mode
// values — relay-mesh is a curated local agent.
setAcpCommand(patch.acpCommand);
setAgentCommand(patch.agentCommand);
setAgentArgs(patch.agentArgs.join(","));
setMcpCommand(patch.mcpCommand);
setEnvVars(patch.envVars);
}}
onTargetChange={setMeshTarget}
onUseMeshChange={(next) => {
setUseMesh(next);
if (!next) {
// Clearing the toggle: drop the model selection so the
// submit guard doesn't fire on a stale value. The runtime
// fields keep whatever the user had — they can re-pick
// ACP runtime or stay with the preset values, their call.
setMeshModelId("");
setMeshTarget(null);
}
}}
useMesh={useMesh}
/>
{/* Run on selector — only shown when backend providers are discovered */}
{!useMesh && backendProviders.length > 0 ? (
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="agent-run-on">
Run on
</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs"
id="agent-run-on"
onChange={(e) => handleRunOnChange(e.target.value)}
value={runOn}
>
<option value="local">This computer</option>
{backendProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.id}
</option>
))}
</select>
</div>
) : null}
{/* Provider mode: trust warning + config fields */}
{isProviderMode && selectedBackendProvider ? (
<div className="space-y-4">
<div className="flex gap-3 rounded-2xl border border-warning/30 bg-warning-bg px-4 py-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p className="text-sm text-warning">
This provider at{" "}
<span className="font-mono font-medium">
{selectedBackendProvider.binaryPath}
</span>{" "}
will receive your agent&apos;s private key. Only use
providers from trusted sources.
</p>
</div>
{probeError ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
Could not probe provider: {probeError}
</p>
) : null}
{probedProvider?.config_schema ? (
<ProviderConfigFields
config={providerConfig}
onChange={setProviderConfig}
schema={probedProvider.config_schema}
/>
) : null}
</div>
) : null}
{/* Local mode: show the ACP runtime selector */}
{!isProviderMode && !useMesh ? (
<CreateAgentRuntimeField
onRuntimeChange={handleProviderChange}
runtimes={runtimes}
runtimesLoading={providersQuery.isLoading}
selectedRuntime={selectedRuntime}
selectedRuntimeId={selectedRuntimeId}
unavailableCount={unavailableCount}
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div className="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)]">
<AgentCreationPreview
avatarUrl={avatarUrl.trim() || null}
disabled={createMutation.isPending || isAvatarUploadPending}
label={name.trim() || "Agent name"}
onClearAvatar={() => setAvatarUrl("")}
onSelectAvatar={setAvatarUrl}
onUploadPendingChange={setIsAvatarUploadPending}
/>
) : null}
<CreateAgentOptionToggles
isSpawnSupported={isSpawnSupported}
onToggleStartOnAppLaunch={() => {
setStartOnAppLaunch((current) => !current);
}}
onToggleSpawnAfterCreate={() => {
if (!spawnToggleDisabled) {
setSpawnAfterCreate((current) => !current);
}
}}
prereqs={prereqs}
startOnAppLaunch={isProviderMode ? false : startOnAppLaunch}
startOnAppLaunchDisabled={isProviderMode}
spawnAfterCreate={isProviderMode ? true : spawnAfterCreate}
spawnToggleDisabled={isProviderMode || spawnToggleDisabled}
/>
<div className="space-y-5">
<CreateAgentBasicsFields name={name} onNameChange={setName} />
<CreateAgentRespondToField
allowlist={respondToAllowlist}
mode={respondTo}
onAllowlistChange={setRespondToAllowlist}
onModeChange={setRespondTo}
/>
<div className="rounded-2xl border border-border/70 bg-muted/20">
<button
aria-expanded={showAdvanced}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
onClick={() => setShowAdvanced((current) => !current)}
type="button"
>
<div>
<p className="text-sm font-semibold tracking-tight">
Advanced setup
</p>
<p className="text-sm text-muted-foreground">
Relay overrides, raw commands, timeout, parallelism, prompt
override, and doctor guidance.
</p>
</div>
<span className="shrink-0 self-center text-muted-foreground">
<ChevronDown
className={`h-4 w-4 transition-transform ${showAdvanced ? "rotate-180" : ""}`}
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="agent-instructions"
>
Agent instructions
</label>
<Textarea
className="min-h-32 resize-y"
data-testid="agent-instructions-input"
id="agent-instructions"
onChange={(event) => setSystemPrompt(event.target.value)}
placeholder="Describe what this agent should do."
value={systemPrompt}
/>
</span>
</button>
</div>
{showAdvanced ? (
<div className="overflow-hidden">
<div className="space-y-5 border-t border-border/60 px-4 py-4">
<CreateAgentRuntimeFields
acpCommand={acpCommand}
agentArgs={agentArgs}
agentCommand={agentCommand}
mcpCommand={mcpCommand}
mcpToolsets={mcpToolsets}
onParallelismChange={setParallelism}
onAcpCommandChange={setAcpCommand}
onAgentArgsChange={setAgentArgs}
onAgentCommandChange={setAgentCommand}
onMcpCommandChange={setMcpCommand}
onMcpToolsetsChange={setMcpToolsets}
onRelayUrlChange={setRelayUrl}
onSystemPromptChange={setSystemPrompt}
onTurnTimeoutChange={setTurnTimeoutSeconds}
parallelism={parallelism}
relayUrl={relayUrl}
selectedRuntimeId={selectedRuntimeId}
systemPrompt={systemPrompt}
turnTimeoutSeconds={turnTimeoutSeconds}
/>
<RelayMeshAgentSection
current={{
acpCommand,
agentCommand,
agentArgs: agentArgs
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0),
mcpCommand,
model: meshModelId || null,
envVars,
}}
modelId={meshModelId}
targetEndpointAddr={meshTarget?.endpointAddr ?? ""}
onModelIdChange={(nextId, patch) => {
setMeshModelId(nextId);
if (patch == null) return;
// Fan out the preset into the existing setters so the rest
// of the dialog (and the submit branch) see normal local-mode
// values — relay-mesh is a curated local agent.
setAcpCommand(patch.acpCommand);
setAgentCommand(patch.agentCommand);
setAgentArgs(patch.agentArgs.join(","));
setMcpCommand(patch.mcpCommand);
setEnvVars(patch.envVars);
}}
onTargetChange={setMeshTarget}
onUseMeshChange={(next) => {
setUseMesh(next);
if (!next) {
// Clearing the toggle: drop the model selection so the
// submit guard doesn't fire on a stale value. The runtime
// fields keep whatever the user had — they can re-pick
// ACP runtime or stay with the preset values, their call.
setMeshModelId("");
setMeshTarget(null);
}
}}
useMesh={useMesh}
/>
<p className="rounded-2xl border border-border/70 bg-background/70 px-4 py-3 text-sm text-muted-foreground">
Local Buzz binary checks and ACP runtime discovery now
live in Settings &gt; Doctor.
</p>
{/* Run on selector — only shown when backend providers are discovered */}
{!useMesh && backendProviders.length > 0 ? (
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="agent-run-on"
>
Run on
</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs"
id="agent-run-on"
onChange={(e) => handleRunOnChange(e.target.value)}
value={runOn}
>
<option value="local">This computer</option>
{backendProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.id}
</option>
))}
</select>
</div>
) : null}
{providersQuery.error instanceof Error ? (
{/* Provider mode: trust warning + config fields */}
{isProviderMode && selectedBackendProvider ? (
<div className="space-y-4">
<div className="flex gap-3 rounded-2xl border border-warning/30 bg-warning-bg px-4 py-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p className="text-sm text-warning">
This provider at{" "}
<span className="font-mono font-medium">
{selectedBackendProvider.binaryPath}
</span>{" "}
will receive your agent&apos;s private key. Only use
providers from trusted sources.
</p>
</div>
{probeError ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{providersQuery.error.message}
Could not probe provider: {probeError}
</p>
) : null}
{prereqsQuery.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{prereqsQuery.error.message}
</p>
{probedProvider?.config_schema ? (
<ProviderConfigFields
config={providerConfig}
onChange={setProviderConfig}
schema={probedProvider.config_schema}
/>
) : null}
</div>
) : null}
{/* Local mode: show the ACP runtime selector */}
{!isProviderMode && !useMesh ? (
<CreateAgentRuntimeField
onRuntimeChange={handleProviderChange}
runtimes={runtimes}
runtimesLoading={providersQuery.isLoading}
selectedRuntime={selectedRuntime}
selectedRuntimeId={selectedRuntimeId}
unavailableCount={unavailableCount}
/>
) : null}
{showModelProviderFields ? (
<AgentModelProviderFields
disabled={createMutation.isPending}
envVars={envVars}
model={model}
onEnvVarsChange={setEnvVars}
onModelChange={setModel}
onProviderChange={setLlmProvider}
open={open}
provider={llmProvider}
runtimeId={selectedRuntimeId}
selectedRuntime={selectedRuntime ?? undefined}
/>
) : null}
<CreateAgentOptionToggles
isSpawnSupported={isSpawnSupported}
onToggleStartOnAppLaunch={() => {
setStartOnAppLaunch((current) => !current);
}}
onToggleSpawnAfterCreate={() => {
if (!spawnToggleDisabled) {
setSpawnAfterCreate((current) => !current);
}
}}
prereqs={prereqs}
startOnAppLaunch={isProviderMode ? false : startOnAppLaunch}
startOnAppLaunchDisabled={isProviderMode}
spawnAfterCreate={isProviderMode ? true : spawnAfterCreate}
spawnToggleDisabled={isProviderMode || spawnToggleDisabled}
/>
<CreateAgentRespondToField
allowlist={respondToAllowlist}
mode={respondTo}
onAllowlistChange={setRespondToAllowlist}
onModeChange={setRespondTo}
/>
<div className="rounded-2xl border border-border/70 bg-muted/20">
<button
aria-expanded={showAdvanced}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
onClick={() => setShowAdvanced((current) => !current)}
type="button"
>
<div>
<p className="text-sm font-semibold tracking-tight">
Advanced setup
</p>
<p className="text-sm text-muted-foreground">
Relay overrides, raw commands, timeout, parallelism, and
doctor guidance.
</p>
</div>
<span className="shrink-0 self-center text-muted-foreground">
<ChevronDown
className={`h-4 w-4 transition-transform ${showAdvanced ? "rotate-180" : ""}`}
/>
</span>
</button>
{showAdvanced ? (
<div className="overflow-hidden">
<div className="space-y-5 border-t border-border/60 px-4 py-4">
<CreateAgentRuntimeFields
acpCommand={acpCommand}
agentArgs={agentArgs}
agentCommand={agentCommand}
mcpCommand={mcpCommand}
mcpToolsets={mcpToolsets}
onParallelismChange={setParallelism}
onAcpCommandChange={setAcpCommand}
onAgentArgsChange={setAgentArgs}
onAgentCommandChange={setAgentCommand}
onMcpCommandChange={setMcpCommand}
onMcpToolsetsChange={setMcpToolsets}
onRelayUrlChange={setRelayUrl}
onTurnTimeoutChange={setTurnTimeoutSeconds}
parallelism={parallelism}
relayUrl={relayUrl}
selectedRuntimeId={selectedRuntimeId}
turnTimeoutSeconds={turnTimeoutSeconds}
/>
<p className="rounded-2xl border border-border/70 bg-background/70 px-4 py-3 text-sm text-muted-foreground">
Local Buzz binary checks and ACP runtime discovery now
live in Settings &gt; Doctor.
</p>
{providersQuery.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{providersQuery.error.message}
</p>
) : null}
{prereqsQuery.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{prereqsQuery.error.message}
</p>
) : null}
</div>
</div>
) : null}
</div>
) : null}
<EnvVarsEditor
disabled={createMutation.isPending}
helperText="Injected when the agent process starts."
onChange={setEnvVars}
value={envVars}
/>
{meshClientError ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
Mesh client failed to start: {meshClientError}
</p>
) : null}
{createMutation.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{createMutation.error.message}
</p>
) : null}
</div>
</div>
<EnvVarsEditor
disabled={createMutation.isPending}
helperText="Injected at spawn. Overrides the persona's env vars on collision."
onChange={setEnvVars}
value={envVars}
/>
{meshClientError ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
Mesh client failed to start: {meshClientError}
</p>
) : null}
{createMutation.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{createMutation.error.message}
</p>
) : null}
</div>
<div className="flex shrink-0 justify-end gap-2 border-t border-border/60 px-6 py-4">
@@ -679,7 +795,9 @@ export function CreateAgentDialog({
? "Connecting to mesh..."
: createMutation.isPending
? "Creating..."
: "Create agent"}
: isAvatarUploadPending
? "Uploading..."
: (draft?.submitLabel ?? "Create agent")}
</Button>
</div>
</div>

View File

@@ -1,7 +1,6 @@
import type { AcpRuntime, ManagedAgentPrereqs } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { describeResolvedCommand } from "./agentUi";
export function CreateAgentBasicsFields({
@@ -110,7 +109,6 @@ export function CreateAgentRuntimeFields({
parallelism,
relayUrl,
selectedRuntimeId,
systemPrompt,
turnTimeoutSeconds,
onAcpCommandChange,
onAgentArgsChange,
@@ -119,7 +117,6 @@ export function CreateAgentRuntimeFields({
onMcpToolsetsChange,
onParallelismChange,
onRelayUrlChange,
onSystemPromptChange,
onTurnTimeoutChange,
}: {
acpCommand: string;
@@ -130,7 +127,6 @@ export function CreateAgentRuntimeFields({
parallelism: string;
relayUrl: string;
selectedRuntimeId: string;
systemPrompt: string;
turnTimeoutSeconds: string;
onAcpCommandChange: (value: string) => void;
onAgentArgsChange: (value: string) => void;
@@ -139,7 +135,6 @@ export function CreateAgentRuntimeFields({
onMcpToolsetsChange: (value: string) => void;
onParallelismChange: (value: string) => void;
onRelayUrlChange: (value: string) => void;
onSystemPromptChange: (value: string) => void;
onTurnTimeoutChange: (value: string) => void;
}) {
return (
@@ -317,26 +312,6 @@ export function CreateAgentRuntimeFields({
(default, canvas, forums, dms, media).
</p>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="agent-system-prompt">
System prompt override
</label>
<Textarea
aria-describedby="help-agent-system-prompt"
data-testid="agent-system-prompt-input"
id="agent-system-prompt"
onChange={(event) => onSystemPromptChange(event.target.value)}
placeholder="Leave blank to send no ACP system prompt"
value={systemPrompt}
/>
<p
className="text-xs text-muted-foreground"
id="help-agent-system-prompt"
>
Blank means no override. buzz-acp will not add a [System] prompt.
</p>
</div>
</>
);
}

View File

@@ -0,0 +1,169 @@
import { FileUp, Plus } from "lucide-react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { useAgentTemplatesQuery } from "@/features/agents/hooks";
import type { AgentTemplate } from "@/shared/api/types";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
/**
* Step 1 of the Create Agent wizard: choose a starting point — a blank
* agent, a built-in starter template, or an imported agent file. Selecting
* anything hands off to the details step (CreateAgentDialog) prefilled.
*/
export function CreateAgentStartDialog({
open,
onImportFile,
onOpenChange,
onPickBlank,
onPickTemplate,
}: {
open: boolean;
onImportFile: (fileBytes: number[], fileName: string) => void;
onOpenChange: (open: boolean) => void;
onPickBlank: () => void;
onPickTemplate: (template: AgentTemplate) => void;
}) {
const templatesQuery = useAgentTemplatesQuery({ enabled: open });
const templates = templatesQuery.data ?? [];
const {
fileInputRef,
isDragOver,
dropHandlers,
handleFileChange,
openFilePicker,
} = useFileImportZone({ onImportFile });
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-2xl overflow-hidden p-0">
<div className="relative flex max-h-[85vh] flex-col" {...dropHandlers}>
{isDragOver ? (
<div className="pointer-events-none absolute inset-1 z-10 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-background/80 backdrop-blur-sm">
<p className="text-sm font-medium text-primary">
Drop an agent file to import
</p>
</div>
) : null}
<DialogHeader className="shrink-0 border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>New agent</DialogTitle>
<DialogDescription>
Choose a starting point. You can adjust everything in the next
step.
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-6 py-5">
<div className="grid gap-2 sm:grid-cols-2">
<button
className="flex items-center gap-3 rounded-2xl border border-border/70 bg-background/70 px-4 py-3 text-left transition-colors hover:border-primary/50 hover:bg-primary/5"
data-testid="create-agent-start-blank"
onClick={onPickBlank}
type="button"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-dashed border-muted-foreground/40 text-muted-foreground">
<Plus className="h-4 w-4" />
</span>
<span className="min-w-0">
<span className="block text-sm font-semibold tracking-tight">
Blank agent
</span>
<span className="block truncate text-xs text-muted-foreground">
Start from scratch
</span>
</span>
</button>
<button
className="flex items-center gap-3 rounded-2xl border border-border/70 bg-background/70 px-4 py-3 text-left transition-colors hover:border-primary/50 hover:bg-primary/5"
data-testid="create-agent-start-import"
onClick={openFilePicker}
type="button"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-dashed border-muted-foreground/40 text-muted-foreground">
<FileUp className="h-4 w-4" />
</span>
<span className="min-w-0">
<span className="block text-sm font-semibold tracking-tight">
Import from file
</span>
<span className="block truncate text-xs text-muted-foreground">
.persona.md, .persona.json, .persona.png, or .zip
</span>
</span>
</button>
</div>
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Templates
</p>
<div className="grid gap-2 sm:grid-cols-2">
{templates.map((template) => (
<TemplateCard
key={template.id}
onPick={onPickTemplate}
template={template}
/>
))}
</div>
</div>
</div>
<input
accept=".md,.json,.png,.zip"
className="hidden"
onChange={handleFileChange}
ref={fileInputRef}
type="file"
/>
</div>
</DialogContent>
</Dialog>
);
}
function TemplateCard({
template,
onPick,
}: {
template: AgentTemplate;
onPick: (template: AgentTemplate) => void;
}) {
const firstLine = template.systemPrompt
.trim()
.split("\n")
.find((line) => line.trim().length > 0);
return (
<button
className="flex items-center gap-3 rounded-2xl border border-border/70 bg-background/70 px-4 py-3 text-left transition-colors hover:border-primary/50 hover:bg-primary/5"
data-testid={`create-agent-template-${template.id}`}
onClick={() => onPick(template)}
type="button"
>
<ProfileAvatar
avatarUrl={template.avatarUrl}
className="h-9 w-9 shrink-0 rounded-lg text-xs"
label={template.displayName}
/>
<span className="min-w-0">
<span className="block text-sm font-semibold tracking-tight">
{template.displayName}
</span>
{firstLine ? (
<span className="block truncate text-xs text-muted-foreground">
{firstLine}
</span>
) : null}
</span>
</button>
);
}

View File

@@ -20,6 +20,7 @@ import {
DialogTitle,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import {
AUTO_MODEL_DROPDOWN_VALUE,
AUTO_PROVIDER_DROPDOWN_VALUE,
@@ -604,7 +605,7 @@ export function EditAgentDialog({
}
type="checkbox"
/>
Inherit runtime from persona
Inherit runtime from team pack
</label>
<p className="text-xs text-muted-foreground">
{inheritHarness
@@ -612,8 +613,8 @@ export function EditAgentDialog({
linkedPersona.runtime
? ` (${linkedPersona.runtime})`
: ""
}. Editing the persona and respawning propagates the new runtime.`
: "Pins this agent to a specific runtime command, overriding the persona's runtime."}
}. Editing the team pack and respawning propagates the new runtime.`
: "Pins this agent to a specific runtime command, overriding the team pack's runtime."}
</p>
</div>
) : null}
@@ -649,6 +650,23 @@ export function EditAgentDialog({
) : null}
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="edit-agent-instructions"
>
Agent instructions
</label>
<Textarea
className="min-h-32 resize-y"
data-testid="agent-system-prompt-input"
id="edit-agent-instructions"
onChange={(event) => setSystemPrompt(event.target.value)}
placeholder="Describe what this agent should do."
value={systemPrompt}
/>
</div>
<CreateAgentRuntimeFields
acpCommand={acpCommand}
agentArgs={agentArgs}
@@ -662,7 +680,6 @@ export function EditAgentDialog({
onMcpToolsetsChange={setMcpToolsets}
onParallelismChange={setParallelism}
onRelayUrlChange={setRelayUrl}
onSystemPromptChange={setSystemPrompt}
onTurnTimeoutChange={setTurnTimeoutSeconds}
parallelism={parallelism}
relayUrl={relayUrl}
@@ -676,15 +693,14 @@ export function EditAgentDialog({
? "custom"
: "inherit"
}
systemPrompt={systemPrompt}
turnTimeoutSeconds={turnTimeoutSeconds}
/>
<EnvVarsEditor
disabled={updateMutation.isPending}
helperText="Per-agent env vars. Override the persona's vars on collision."
helperText="Per-agent env vars. Override the team pack's vars on collision."
inheritedFrom={inheritedEnvVars}
inheritedLabel="persona"
inheritedLabel="team pack"
onChange={setEnvVars}
value={envVars}
/>

View File

@@ -1,393 +0,0 @@
import * as React from "react";
import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
import { Badge } from "@/shared/ui/badge";
import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge";
import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore";
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
import { useNow } from "@/shared/lib/useNow";
import type {
ManagedAgent,
PresenceLookup,
PresenceStatus,
} from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { AgentConfigPanel } from "./AgentConfigPanel";
import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError";
import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel";
import { truncatePubkey } from "./agentUi";
export function ManagedAgentRow({
agent,
channelIdToName,
channelNames,
isLogSelected,
logContent,
logError,
logLoading,
personaLabelsById,
presenceLoaded,
presenceLookup,
onOpenProfile,
onSelectLogAgent,
}: {
agent: ManagedAgent;
channelIdToName: Record<string, string>;
channelNames: { id: string; name: string }[];
isLogSelected: boolean;
logContent: string | null;
logError: Error | null;
logLoading: boolean;
personaLabelsById: Record<string, string>;
presenceLoaded: boolean;
presenceLookup: PresenceLookup;
onOpenProfile: (pubkey: string) => void;
onSelectLogAgent: (pubkey: string | null) => void;
}) {
const isLocal = agent.backend.type === "local";
const runtimeSource =
agent.backend.type === "provider" ? `Remote (${agent.backend.id})` : null;
const personaLabel = agent.personaId
? (personaLabelsById[agent.personaId] ?? null)
: null;
const presenceStatus = presenceLookup[agent.pubkey.trim().toLowerCase()];
const activeTurns = useActiveAgentTurns(agent.pubkey);
const activeWorkingChannels = React.useMemo(
() =>
activeTurns
.map(({ channelId, anchorAt }) => ({
id: channelId,
name: channelIdToName[channelId] ?? channelId,
anchorAt,
}))
.slice(0, 3),
[activeTurns, channelIdToName],
);
const isWorking = activeWorkingChannels.length > 0;
const processDetail =
agent.pid !== null
? `PID ${agent.pid}`
: agent.lastExitCode !== null
? `Exit ${agent.lastExitCode}`
: isLocal
? "Ready to launch"
: "Managed remotely";
// When the harness recovered a meaningful error string from the agent's
// log tail (Max's seam in `managed_agents/storage.rs`), promote it to
// user-visible copy below the process detail. Specifically renders the
// friendly "Relay mesh denied this agent — check your relay membership."
// for auth failures so the user knows it's a membership thing, not a
// crash. Generic exits stay verbatim so we don't lie about other failures.
const friendlyError = friendlyAgentLastError(agent.lastError);
return (
<div
className={cn(
"overflow-hidden transition-colors",
isLogSelected ? "bg-primary/5" : "hover:bg-muted/20",
)}
data-testid={`managed-agent-${agent.pubkey}`}
>
<div className="flex items-start gap-3 px-4 py-3">
{isLocal ? (
<button
aria-expanded={isLogSelected}
className="-m-1 min-w-0 flex-1 rounded-lg p-1 text-left transition-colors hover:bg-background/40"
onClick={() =>
onSelectLogAgent(isLogSelected ? null : agent.pubkey)
}
type="button"
>
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.8fr)_minmax(120px,0.8fr)_minmax(0,1.1fr)] lg:gap-4">
<AgentSummary
activeWorkingChannels={activeWorkingChannels}
agent={agent}
channelNames={channelNames}
isExpandable
isLogSelected={isLogSelected}
personaLabel={personaLabel}
presenceStatus={presenceStatus}
/>
<StatusBlock
friendlyError={friendlyError}
isWorking={isWorking}
presenceLoaded={presenceLoaded}
presenceStatus={presenceStatus}
processDetail={processDetail}
status={agent.status}
/>
<RuntimeBlock agent={agent} runtimeSource={runtimeSource} />
</div>
</button>
) : (
<div className="min-w-0 flex-1">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.8fr)_minmax(120px,0.8fr)_minmax(0,1.1fr)] lg:gap-4">
<AgentSummary
activeWorkingChannels={activeWorkingChannels}
agent={agent}
channelNames={channelNames}
isExpandable={false}
isLogSelected={false}
personaLabel={personaLabel}
presenceStatus={presenceStatus}
/>
<StatusBlock
friendlyError={friendlyError}
isWorking={isWorking}
presenceLoaded={presenceLoaded}
presenceStatus={presenceStatus}
processDetail={processDetail}
status={agent.status}
/>
<RuntimeBlock agent={agent} runtimeSource={runtimeSource} />
</div>
</div>
)}
<div className="flex shrink-0 items-start gap-2 lg:pt-0.5">
<Button
onClick={() => onOpenProfile(agent.pubkey)}
size="sm"
type="button"
variant="outline"
>
Manage
</Button>
</div>
</div>
{isLocal && isLogSelected ? (
<div
className="border-t border-border/60 bg-background/60 px-4 py-4"
data-testid="managed-agent-log-row"
>
<ManagedAgentLogPanel
error={logError}
isLoading={logLoading}
logContent={logContent}
selectedAgent={agent}
variant="inline"
/>
<div className="mt-4 border-t border-border/50 pt-3">
<p className="mb-2 text-xs font-medium text-muted-foreground">
Configuration
</p>
<AgentConfigPanel pubkey={agent.pubkey} />
</div>
</div>
) : null}
</div>
);
}
function AgentSummary({
activeWorkingChannels,
agent,
channelNames,
isExpandable,
isLogSelected,
personaLabel,
presenceStatus,
}: {
activeWorkingChannels: { id: string; name: string; anchorAt: number }[];
agent: ManagedAgent;
channelNames: { id: string; name: string }[];
isExpandable: boolean;
isLogSelected: boolean;
personaLabel: string | null;
presenceStatus: PresenceStatus | undefined;
}) {
const { goChannel } = useAppNavigation();
return (
<div className="min-w-0">
<div className="flex items-start gap-3">
{isExpandable ? (
isLogSelected ? (
<ChevronDown className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)
) : (
<span className="mt-0.5 h-4 w-4 shrink-0" />
)}
{presenceStatus ? (
<PresenceDot className="mt-1 shrink-0" status={presenceStatus} />
) : (
<span className="mt-1 h-2 w-2 shrink-0" />
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate font-medium text-foreground">{agent.name}</p>
{personaLabel ? (
<Badge variant="secondary">{personaLabel}</Badge>
) : null}
<AgentOriginBadge agent={agent} />
{agent.personaOutOfDate ? (
<Badge className="gap-1" variant="warning">
<AlertTriangle className="h-3 w-3" />
Out of date
</Badge>
) : null}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span className="font-mono">{truncatePubkey(agent.pubkey)}</span>
{agent.backend.type === "local" ? (
<span>
{agent.startOnAppLaunch ? "Auto-start" : "Manual start"}
</span>
) : (
<span>Remote deployment</span>
)}
</div>
{agent.personaOutOfDate ? (
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
Persona updated since this agent was created. Respawn to apply the
new configuration.
</p>
) : null}
{channelNames.length > 0 ? (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{channelNames.map((channel) => (
<Badge
className="cursor-pointer normal-case tracking-normal hover:opacity-80"
key={channel.id}
variant="outline"
onClick={(e) => {
e.stopPropagation();
void goChannel(channel.id);
}}
>
# {channel.name}
</Badge>
))}
</div>
) : null}
{activeWorkingChannels.length > 0 ? (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{activeWorkingChannels.map((channel) => (
<WorkingBadge
key={`working-${channel.id}`}
channelId={channel.id}
name={channel.name}
anchorAt={channel.anchorAt}
onNavigate={goChannel}
/>
))}
</div>
) : null}
</div>
</div>
</div>
);
}
function WorkingBadge({
channelId,
name,
anchorAt,
onNavigate,
}: {
channelId: string;
name: string;
anchorAt: number;
onNavigate: (channelId: string) => void;
}) {
// The 1s tick lives here, at the leaf, so only visible working badges
// re-render each second — idle rows never mount this hook.
const now = useNow(1000);
return (
<Badge
className="cursor-pointer motion-safe:animate-pulse normal-case tracking-normal hover:opacity-80"
variant="default"
onClick={(e) => {
e.stopPropagation();
onNavigate(channelId);
}}
>
Working in #{name} · {formatElapsed(now - anchorAt)}
</Badge>
);
}
function StatusBlock({
friendlyError,
isWorking,
presenceLoaded,
presenceStatus,
processDetail,
status,
}: {
friendlyError: ReturnType<typeof friendlyAgentLastError>;
isWorking: boolean;
presenceLoaded: boolean;
presenceStatus: PresenceStatus | undefined;
processDetail: string;
status: ManagedAgent["status"];
}) {
return (
<div className="space-y-1 lg:pt-0.5">
<p className="text-2xs font-semibold uppercase tracking-[0.16em] text-muted-foreground lg:hidden">
Status
</p>
<AgentStatusBadge
isWorking={isWorking}
presenceLoaded={presenceLoaded}
presenceStatus={presenceStatus}
status={status}
/>
<p className="text-xs text-muted-foreground">{processDetail}</p>
{friendlyError ? (
<p
className={cn(
"text-xs",
friendlyError.severity === "denied"
? "text-destructive"
: "text-muted-foreground",
)}
data-testid="managed-agent-last-error"
>
{friendlyError.copy}
</p>
) : null}
</div>
);
}
function RuntimeBlock({
agent,
runtimeSource,
}: {
agent: ManagedAgent;
runtimeSource: string | null;
}) {
return (
<div className="space-y-1 lg:pt-0.5">
<p className="text-2xs font-semibold uppercase tracking-[0.16em] text-muted-foreground lg:hidden">
Runtime
</p>
<p className="truncate font-mono text-xs text-foreground">
{agent.agentCommand}
</p>
{runtimeSource || agent.model ? (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{runtimeSource ? <span>{runtimeSource}</span> : null}
{agent.model ? <span>{agent.model}</span> : null}
</div>
) : null}
</div>
);
}
function AgentOriginBadge({ agent }: { agent: ManagedAgent }) {
return (
<Badge variant="outline">
{agent.backend.type === "local" ? "Local" : "Remote"}
</Badge>
);
}

View File

@@ -101,7 +101,7 @@ export function ModelPicker({
acpConfigOption: "from ACP config",
envVar: "from env",
configFile: "from config file",
personaDefault: "persona default",
personaDefault: "profile default",
runtimeOverride: "live override",
};
return labels[origin] ?? null;

View File

@@ -1,92 +0,0 @@
import { BookUser, CopyPlus, Ellipsis, Pencil, Trash2 } from "lucide-react";
import type { AgentPersona } from "@/shared/api/types";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
export function PersonaActionsMenu({
isActionPending,
isPending,
persona,
onDuplicate,
onEdit,
onShare,
onDeactivate,
onDelete,
}: {
isActionPending: boolean;
isPending: boolean;
persona: AgentPersona;
onDuplicate: (persona: AgentPersona) => void;
onEdit: (persona: AgentPersona) => void;
onShare: (persona: AgentPersona) => void;
onDeactivate: (persona: AgentPersona) => void;
onDelete: (persona: AgentPersona) => void;
}) {
const disabled = isActionPending || isPending;
const canEdit = !persona.isBuiltIn && !persona.sourceTeam;
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
aria-label={`Open actions for ${persona.displayName}`}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
type="button"
>
<Ellipsis className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem disabled={disabled} onClick={() => onShare(persona)}>
<BookUser className="h-4 w-4" />
Catalog options
</DropdownMenuItem>
{canEdit ? (
<DropdownMenuItem disabled={disabled} onClick={() => onEdit(persona)}>
<Pencil className="h-4 w-4" />
Edit
</DropdownMenuItem>
) : null}
<DropdownMenuItem
disabled={disabled}
onClick={() => onDuplicate(persona)}
>
<CopyPlus className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
{persona.sourceTeam ? (
<DropdownMenuItem disabled>
<Trash2 className="h-4 w-4" />
Managed by team
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={disabled}
onClick={() => {
if (persona.isBuiltIn) {
onDeactivate(persona);
return;
}
onDelete(persona);
}}
>
<Trash2 className="h-4 w-4" />
Remove from My Agents
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,14 +0,0 @@
import { cn } from "@/shared/lib/cn";
type PersonaAddedByProps = {
className?: string;
};
export function PersonaAddedBy({ className }: PersonaAddedByProps) {
return (
<p className={cn("truncate text-xs leading-tight", className)}>
<span className="text-muted-foreground/55">Added by</span>{" "}
<span className="text-muted-foreground">You</span>
</p>
);
}

View File

@@ -1,65 +0,0 @@
import { Input } from "@/shared/ui/input";
import { cn } from "@/shared/lib/cn";
import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor";
import {
PERSONA_FIELD_CONTROL_CLASS,
PERSONA_FIELD_SHELL_CLASS,
} from "./personaDialogPickers";
const PERSONA_LABEL_OPTIONAL_CLASS =
"ml-1 text-xs font-normal text-muted-foreground/50";
export function PersonaAdvancedFields({
disabled,
envVars,
namePoolText,
onEnvVarsChange,
onNamePoolTextChange,
}: {
disabled: boolean;
envVars: EnvVarsValue;
namePoolText: string;
onEnvVarsChange: (value: EnvVarsValue) => void;
onNamePoolTextChange: (value: string) => void;
}) {
return (
<div className="space-y-5 pt-2">
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="persona-name-pool"
>
Instance name pool
<span className={PERSONA_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
autoCapitalize="words"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={disabled}
id="persona-name-pool"
onChange={(event) => onNamePoolTextChange(event.target.value)}
placeholder="Birch, Compass, Ridge, Thistle"
spellCheck={false}
value={namePoolText}
/>
</div>
</div>
<EnvVarsEditor
disabled={disabled}
onChange={onEnvVarsChange}
value={envVars}
/>
</div>
);
}

View File

@@ -1,163 +0,0 @@
import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { AgentPersona } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { promptPreview } from "@/shared/lib/promptPreview";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/shared/ui/sheet";
import { PersonaCatalogSelectionBadge } from "./PersonaCatalogSelectionBadge";
import {
getPersonaCatalogDetailSelectionCopy,
getPersonaCatalogSelectionAriaLabel,
} from "./personaLibraryCopy";
type PersonaCatalogDetailsSheetProps = {
feedbackErrorMessage: string | null;
feedbackNoticeMessage: string | null;
isPending: boolean;
onOpenChange: (open: boolean) => void;
onTogglePersona: (persona: AgentPersona) => void;
open: boolean;
persona: AgentPersona | null;
};
export function PersonaCatalogDetailsSheet({
feedbackErrorMessage,
feedbackNoticeMessage,
isPending,
onOpenChange,
onTogglePersona,
open,
persona,
}: PersonaCatalogDetailsSheetProps) {
const preview = persona ? promptPreview(persona.systemPrompt) : "";
const isSelected = persona ? isCatalogPersonaSelected(persona) : false;
const selectionCopy = getPersonaCatalogDetailSelectionCopy(isSelected);
return (
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetContent
className="flex w-full flex-col gap-0 overflow-hidden bg-background p-0 sm:max-w-xl"
data-testid="persona-catalog-details-sheet"
>
{persona ? (
<>
<SheetHeader className="relative z-10 space-y-4 bg-background/25 px-6 py-6 pr-16 text-left shadow-[0_4px_24px_rgba(0,0,0,0.06)] backdrop-blur-xl supports-[backdrop-filter]:bg-background/20 dark:shadow-[0_4px_24px_rgba(0,0,0,0.25)]">
<div className="flex items-start gap-3">
<ProfileAvatar
avatarUrl={persona.avatarUrl}
className="h-12 w-12 text-sm"
label={persona.displayName}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<SheetTitle className="truncate text-xl">
{persona.displayName}
</SheetTitle>
<PersonaCatalogSelectionBadge isActive={isSelected} />
</div>
<SheetDescription className="mt-2">
{preview || "No summary available."}
</SheetDescription>
</div>
</div>
</SheetHeader>
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-6">
<button
aria-label={getPersonaCatalogSelectionAriaLabel(
persona.displayName,
isSelected,
)}
aria-pressed={isSelected}
className={cn(
"w-full rounded-xl border p-4 text-left transition-[background-color,border-color,box-shadow] focus:outline-hidden focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2",
isSelected
? "border-primary bg-primary/10 text-foreground"
: "border-border/80 bg-background/60 text-muted-foreground hover:bg-accent hover:text-accent-foreground",
isPending && "cursor-not-allowed opacity-70",
)}
data-state={isSelected ? "selected" : "available"}
data-testid={`persona-catalog-detail-selection-target-${persona.id}`}
disabled={isPending}
onClick={() => {
onTogglePersona(persona);
}}
type="button"
>
<div className="flex items-start justify-between gap-4">
<div>
<p
className="text-sm font-semibold tracking-tight"
data-testid="persona-catalog-detail-selection-title"
>
{selectionCopy.title}
</p>
<p
className="mt-1 text-sm text-muted-foreground"
data-testid="persona-catalog-detail-selection-description"
>
{selectionCopy.description}
</p>
</div>
<PersonaCatalogSelectionBadge isActive={isSelected} />
</div>
</button>
{feedbackNoticeMessage ? (
<p className="rounded-2xl border border-primary/20 bg-primary/10 px-4 py-3 text-sm text-primary">
{feedbackNoticeMessage}
</p>
) : null}
{feedbackErrorMessage ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{feedbackErrorMessage}
</p>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-border/70 bg-card/70 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Type
</p>
<p className="mt-2 text-sm font-medium">Built-in persona</p>
</div>
<div className="rounded-xl border border-border/70 bg-card/70 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Preferred model
</p>
<p className="mt-2 text-sm font-medium">
{persona.model ?? "Use app default"}
</p>
</div>
<div className="rounded-xl border border-border/70 bg-card/70 p-4 sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Preferred runtime
</p>
<p className="mt-2 text-sm font-medium">
{persona.runtime ?? "Use app default"}
</p>
</div>
</div>
<div className="rounded-xl border border-border/70 bg-card/70 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
System prompt
</p>
<pre className="mt-3 whitespace-pre-wrap break-words font-sans text-sm leading-6 text-foreground">
{persona.systemPrompt}
</pre>
</div>
</div>
</>
) : null}
</SheetContent>
</Sheet>
);
}

View File

@@ -1,373 +0,0 @@
import * as React from "react";
import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { AgentPersona } from "@/shared/api/types";
import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Dialog } from "@/shared/ui/dialog";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import { Markdown } from "@/shared/ui/markdown";
import { Skeleton } from "@/shared/ui/skeleton";
import { PersonaAddedBy } from "./PersonaAddedBy";
import { personaCatalogCopy } from "./personaLibraryCopy";
type PersonaCatalogDialogProps = {
error: Error | null;
feedbackErrorMessage: string | null;
feedbackNoticeMessage: string | null;
isLoading: boolean;
isPending: boolean;
onClearFeedback: () => void;
onOpenChange: (open: boolean) => void;
onSelectPersona: (persona: AgentPersona, active: boolean) => void;
open: boolean;
personas: AgentPersona[];
};
const agentInstructionMarkdownClassName = [
"mt-3 leading-6 text-muted-foreground [&_blockquote]:!text-muted-foreground [&_code]:!text-muted-foreground [&_li]:text-muted-foreground [&_ol]:text-muted-foreground [&_p]:text-muted-foreground [&_strong]:text-muted-foreground [&_td]:text-muted-foreground [&_ul]:text-muted-foreground",
"[&>h1]:!text-sm [&>h1]:!font-semibold [&>h1]:!leading-6 [&>h1]:!tracking-normal [&>h1]:!text-foreground",
"[&>h2]:!text-sm [&>h2]:!font-semibold [&>h2]:!leading-6 [&>h2]:!tracking-normal [&>h2]:!text-foreground",
"[&>h3]:!text-sm [&>h3]:!font-semibold [&>h3]:!leading-6 [&>h3]:!tracking-normal [&>h3]:!text-foreground",
"[&>h4]:!text-sm [&>h4]:!font-semibold [&>h4]:!leading-6 [&>h4]:!tracking-normal [&>h4]:!text-foreground",
"[&>h5]:!text-sm [&>h5]:!font-semibold [&>h5]:!leading-6 [&>h5]:!tracking-normal [&>h5]:!text-foreground",
"[&>h6]:!text-sm [&>h6]:!font-semibold [&>h6]:!leading-6 [&>h6]:!tracking-normal [&>h6]:!text-foreground",
].join(" ");
export function PersonaCatalogDialog({
error,
feedbackErrorMessage,
feedbackNoticeMessage,
isLoading,
isPending,
onClearFeedback,
onOpenChange,
onSelectPersona,
open,
personas,
}: PersonaCatalogDialogProps) {
const contentRef = React.useRef<HTMLDivElement | null>(null);
const [selectedPersonaId, setSelectedPersonaId] = React.useState<
string | null
>(null);
const selectedPersona = React.useMemo(() => {
if (personas.length === 0) {
return null;
}
return (
personas.find((persona) => persona.id === selectedPersonaId) ??
personas[0]
);
}, [personas, selectedPersonaId]);
React.useEffect(() => {
if (!open) {
return;
}
if (personas.length === 0) {
setSelectedPersonaId(null);
return;
}
setSelectedPersonaId((current) =>
current && personas.some((persona) => persona.id === current)
? current
: personas[0].id,
);
}, [open, personas]);
useFeedbackToasts(feedbackNoticeMessage, feedbackErrorMessage);
const selectedPersonaIsActive = selectedPersona
? isCatalogPersonaSelected(selectedPersona)
: false;
const handleUseSelectedPersona = () => {
if (!selectedPersona || selectedPersonaIsActive) {
return;
}
onClearFeedback();
onSelectPersona(selectedPersona, true);
};
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<ChooserDialogContent
className="h-[42rem] max-w-4xl"
contentClassName="flex min-h-0 flex-1 p-0"
data-testid="persona-catalog-dialog"
description={personaCatalogCopy.dialogDescription}
headerClassName="bg-sidebar pb-3 text-sidebar-foreground"
headerTestId="persona-catalog-dialog-header"
onOpenAutoFocus={(event) => {
event.preventDefault();
contentRef.current?.focus();
}}
ref={contentRef}
scrollAreaClassName="flex min-h-0 overflow-hidden px-0"
scrollAreaTestId="persona-catalog-dialog-body"
tabIndex={-1}
title={personaCatalogCopy.dialogTitle}
>
<PersonaCatalogChooser
error={error}
isLoading={isLoading}
isPending={isPending}
isSelectedPersonaActive={selectedPersonaIsActive}
onUsePersona={handleUseSelectedPersona}
onSelectPersona={setSelectedPersonaId}
personas={personas}
selectedPersona={selectedPersona}
selectedPersonaId={selectedPersona?.id ?? null}
/>
</ChooserDialogContent>
</Dialog>
);
}
type PersonaCatalogChooserProps = {
error: Error | null;
isLoading: boolean;
isPending: boolean;
isSelectedPersonaActive: boolean;
onUsePersona: () => void;
onSelectPersona: (personaId: string) => void;
personas: AgentPersona[];
selectedPersona: AgentPersona | null;
selectedPersonaId: string | null;
};
function PersonaCatalogChooser({
error,
isLoading,
isPending,
isSelectedPersonaActive,
onUsePersona,
onSelectPersona,
personas,
selectedPersona,
selectedPersonaId,
}: PersonaCatalogChooserProps) {
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-sidebar sm:flex-row">
<div className="flex max-h-56 min-h-0 flex-col sm:max-h-none sm:w-56">
<div
className="min-h-0 flex-1 overflow-y-auto px-2 py-3"
data-testid="persona-catalog-dialog-scroll-area"
>
{isLoading ? <PersonaCatalogListSkeleton /> : null}
{!isLoading && personas.length > 0 ? (
<div className="space-y-1">
{personas.map((persona) => {
const isCurrent = persona.id === selectedPersonaId;
return (
<button
aria-current={isCurrent ? "true" : undefined}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-4 py-1.5 text-left transition-[background-color,color,box-shadow] focus:outline-hidden focus-visible:ring-2 focus-visible:ring-sidebar-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar",
isCurrent
? "bg-sidebar-active text-sidebar-active-foreground"
: "text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
data-testid={`persona-catalog-list-item-${persona.id}`}
key={persona.id}
onClick={() => {
onSelectPersona(persona.id);
}}
type="button"
>
<ProfileAvatar
avatarUrl={persona.avatarUrl}
className="h-6 w-6 text-3xs"
label={persona.displayName}
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{persona.displayName}
</span>
</button>
);
})}
</div>
) : null}
</div>
</div>
<div className="relative z-10 ml-px flex min-h-0 flex-1 flex-col overflow-hidden rounded-tl-xl bg-background shadow-[-1px_0_0_0_hsl(var(--sidebar-border)/0.45)]">
<div
className="min-h-0 flex-1 overflow-y-auto px-5 pb-24 pt-5"
data-testid="persona-catalog-detail-pane"
>
{isLoading ? <PersonaCatalogDetailSkeleton /> : null}
{!isLoading && selectedPersona ? (
<PersonaCatalogDetail persona={selectedPersona} />
) : null}
{!isLoading && personas.length === 0 && !error ? (
<div className="flex min-h-80 items-center justify-center rounded-lg border border-dashed border-border/70 px-6 text-center">
<div>
<p className="text-sm font-semibold">
{personaCatalogCopy.emptyCatalogTitle}
</p>
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
{personaCatalogCopy.emptyCatalogDescription}
</p>
</div>
</div>
) : null}
{error ? (
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error.message}
</p>
) : null}
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-end bg-gradient-to-t from-background via-background/95 to-transparent px-5 pb-4 pt-12">
<Button
aria-label={
selectedPersona && isSelectedPersonaActive
? `${selectedPersona.displayName} is already in My Agents`
: selectedPersona
? `Add ${selectedPersona.displayName} from Agent Catalog`
: undefined
}
data-testid={
selectedPersona
? `persona-catalog-use-agent-target-${selectedPersona.id}`
: "persona-catalog-use-agent-target"
}
className="pointer-events-auto"
disabled={!selectedPersona || isSelectedPersonaActive || isPending}
onClick={onUsePersona}
size="sm"
type="button"
>
{isSelectedPersonaActive
? personaCatalogCopy.addedAction
: personaCatalogCopy.useAction}
</Button>
</div>
</div>
</div>
);
}
function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<ProfileAvatar
avatarUrl={persona.avatarUrl}
className="h-12 w-12 text-sm"
label={persona.displayName}
/>
<div className="min-w-0">
<h3 className="truncate text-xl font-semibold leading-snug">
{persona.displayName}
</h3>
{persona.isBuiltIn ? null : <PersonaAddedBy className="mt-0.5" />}
</div>
</div>
<PersonaCatalogMetaGroup
items={[
{
label: "Type",
value: persona.isBuiltIn ? "Built-in persona" : "Custom persona",
},
{
label: "Preferred model",
value: persona.model ?? "Use app default",
},
{
label: "Preferred runtime",
value: persona.runtime ?? "Use app default",
},
]}
/>
<div className="pt-3">
<p className="text-base font-semibold text-foreground">
Agent instruction
</p>
<Markdown
className={agentInstructionMarkdownClassName}
content={persona.systemPrompt}
interactive={false}
/>
</div>
</div>
);
}
function PersonaCatalogMetaGroup({
items,
}: {
items: { label: string; value: string }[];
}) {
return (
<div className="rounded-lg border border-border/70 bg-card/70">
<div className="grid sm:grid-cols-3">
{items.map((item, index) => (
<div
className={cn(
"relative px-4 py-3",
index > 0 &&
"border-t border-border/60 sm:border-t-0 sm:before:absolute sm:before:bottom-3 sm:before:left-0 sm:before:top-3 sm:before:w-px sm:before:bg-border/70",
)}
key={item.label}
>
<p className="text-xs font-semibold text-muted-foreground">
{item.label}
</p>
<p className="mt-2 text-sm font-medium text-foreground">
{item.value}
</p>
</div>
))}
</div>
</div>
);
}
function PersonaCatalogListSkeleton() {
return (
<div className="space-y-2">
{["first", "second", "third", "fourth", "fifth"].map((key) => (
<div
className="flex items-center gap-2 rounded-lg px-4 py-1.5"
key={key}
>
<Skeleton className="h-6 w-6 rounded-full" />
<Skeleton className="h-4 w-28" />
</div>
))}
</div>
);
}
function PersonaCatalogDetailSkeleton() {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Skeleton className="h-12 w-12 rounded-full" />
<Skeleton className="h-6 w-40" />
</div>
<div className="grid overflow-hidden rounded-lg border border-border/70 sm:grid-cols-3">
<Skeleton className="h-20 rounded-none" />
<Skeleton className="h-20 rounded-none" />
<Skeleton className="h-20 rounded-none" />
</div>
<Skeleton className="h-48 rounded-lg" />
</div>
);
}

View File

@@ -1,166 +0,0 @@
import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog";
import type { AgentPersona } from "@/shared/api/types";
import { promptPreview } from "@/shared/lib/promptPreview";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import { Skeleton } from "@/shared/ui/skeleton";
import { PersonaCatalogSelectionBadge } from "./PersonaCatalogSelectionBadge";
import { PersonaIdentity } from "./PersonaIdentity";
import {
getPersonaCatalogSelectionActionCopy,
getPersonaCatalogSelectionAriaLabel,
personaCatalogCopy,
} from "./personaLibraryCopy";
type PersonaCatalogSectionProps = {
emptyDescription?: string;
emptyTitle?: string;
error: Error | null;
isLoading: boolean;
isPending: boolean;
onTogglePersona: (persona: AgentPersona) => void;
onViewDetails: (persona: AgentPersona) => void;
personas: AgentPersona[];
showHeader?: boolean;
};
export function PersonaCatalogSection({
emptyDescription = personaCatalogCopy.emptyCatalogDescription,
emptyTitle = personaCatalogCopy.emptyCatalogTitle,
error,
isLoading,
isPending,
onTogglePersona,
onViewDetails,
personas,
showHeader = true,
}: PersonaCatalogSectionProps) {
return (
<section className="space-y-4" data-testid="agents-persona-catalog">
{showHeader ? (
<div>
<h3 className="text-sm font-semibold tracking-tight">
{personaCatalogCopy.title}
</h3>
<p className="text-sm text-muted-foreground">
{personaCatalogCopy.description}
</p>
</div>
) : null}
{isLoading ? (
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
{["first", "second", "third", "fourth"].map((key) => (
<Card className="p-3" key={key}>
<div className="flex items-center gap-2.5">
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-20" />
</div>
</div>
</Card>
))}
</div>
) : null}
{!isLoading && personas.length > 0 ? (
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
{personas.map((persona) => {
const preview = promptPreview(persona.systemPrompt);
const isSelected = isCatalogPersonaSelected(persona);
return (
<div
className={cn(
"group relative flex flex-col gap-4 rounded-xl border p-3 shadow-xs transition-[background-color,border-color,box-shadow]",
isPending
? "cursor-not-allowed opacity-70"
: "cursor-pointer",
isSelected
? "border-primary bg-primary/10 text-foreground"
: "border-border/80 bg-background/60 hover:bg-accent hover:text-accent-foreground",
)}
data-testid={`persona-catalog-card-${persona.id}`}
data-state={isSelected ? "selected" : "available"}
key={persona.id}
>
<button
aria-label={getPersonaCatalogSelectionAriaLabel(
persona.displayName,
isSelected,
)}
aria-pressed={isSelected}
className="absolute inset-0 z-0 rounded-xl focus:outline-hidden focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2"
data-testid={`persona-catalog-card-target-${persona.id}`}
disabled={isPending}
onClick={() => {
onTogglePersona(persona);
}}
type="button"
/>
<div className="pointer-events-none relative z-10 flex h-full flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<PersonaIdentity
className="min-w-0 flex-1"
persona={persona}
showBuiltInBadge={false}
showPromptTooltip={false}
/>
<PersonaCatalogSelectionBadge isActive={isSelected} />
</div>
<p className="min-h-12 text-xs leading-5 text-muted-foreground">
{preview}
</p>
<div
className={cn(
"mt-auto flex items-center justify-between gap-3 border-t pt-3",
isSelected ? "border-primary/20" : "border-border/60",
)}
>
<Button
className="pointer-events-auto"
data-testid={`persona-catalog-details-${persona.id}`}
onClick={() => {
onViewDetails(persona);
}}
size="sm"
type="button"
variant="ghost"
>
{personaCatalogCopy.detailsAction}
</Button>
<span className="text-xs font-medium text-muted-foreground">
{getPersonaCatalogSelectionActionCopy(isSelected)}
</span>
</div>
</div>
</div>
);
})}
</div>
) : null}
{!isLoading && personas.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/70 px-6 py-10 text-center">
<p className="text-sm font-semibold tracking-tight">{emptyTitle}</p>
<p className="mt-2 text-sm text-muted-foreground">
{emptyDescription}
</p>
</div>
) : null}
{error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error.message}
</p>
) : null}
</section>
);
}

View File

@@ -1,29 +0,0 @@
import { Check } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { personaCatalogCopy } from "./personaLibraryCopy";
type PersonaCatalogSelectionBadgeProps = {
isActive: boolean;
};
export function PersonaCatalogSelectionBadge({
isActive,
}: PersonaCatalogSelectionBadgeProps) {
return (
<span
className={cn(
"inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-1 text-2xs font-semibold uppercase tracking-[0.14em]",
isActive
? "bg-primary text-primary-foreground shadow-xs"
: "border border-border/70 bg-background/85 text-muted-foreground",
)}
>
{isActive ? <Check className="h-4 w-4" /> : null}
{isActive
? personaCatalogCopy.selectedState
: personaCatalogCopy.availableState}
</span>
);
}

View File

@@ -1,79 +0,0 @@
import * as React from "react";
import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog";
import type { AgentPersona } from "@/shared/api/types";
import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
import { PersonaCatalogDetailsSheet } from "./PersonaCatalogDetailsSheet";
import { PersonaCatalogSection } from "./PersonaCatalogSection";
type PersonaCatalogSurfaceProps = {
error: Error | null;
feedbackErrorMessage: string | null;
feedbackNoticeMessage: string | null;
isLoading: boolean;
isPending: boolean;
onClearFeedback: () => void;
onSelectPersona: (persona: AgentPersona, active: boolean) => void;
personas: AgentPersona[];
showHeader?: boolean;
};
export function PersonaCatalogSurface({
error,
feedbackErrorMessage,
feedbackNoticeMessage,
isLoading,
isPending,
onClearFeedback,
onSelectPersona,
personas,
showHeader = true,
}: PersonaCatalogSurfaceProps) {
const [detailPersonaId, setDetailPersonaId] = React.useState<string | null>(
null,
);
const detailPersona = React.useMemo(
() =>
detailPersonaId
? (personas.find((persona) => persona.id === detailPersonaId) ?? null)
: null,
[detailPersonaId, personas],
);
const handleTogglePersona = (persona: AgentPersona) => {
onSelectPersona(persona, !isCatalogPersonaSelected(persona));
};
useFeedbackToasts(feedbackNoticeMessage, feedbackErrorMessage);
return (
<>
<PersonaCatalogSection
error={error}
isLoading={isLoading}
isPending={isPending}
onTogglePersona={handleTogglePersona}
onViewDetails={(persona) => {
onClearFeedback();
setDetailPersonaId(persona.id);
}}
personas={personas}
showHeader={showHeader}
/>
<PersonaCatalogDetailsSheet
feedbackErrorMessage={feedbackErrorMessage}
feedbackNoticeMessage={feedbackNoticeMessage}
isPending={isPending}
onOpenChange={(open) => {
if (!open) {
setDetailPersonaId(null);
}
}}
onTogglePersona={handleTogglePersona}
open={detailPersonaId !== null}
persona={detailPersona}
/>
</>
);
}

View File

@@ -1,61 +0,0 @@
import type { AgentPersona } from "@/shared/api/types";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
type PersonaDeleteDialogProps = {
open: boolean;
persona: AgentPersona | null;
onConfirm: (persona: AgentPersona) => void;
onOpenChange: (open: boolean) => void;
};
export function PersonaDeleteDialog({
open,
persona,
onConfirm,
onOpenChange,
}: PersonaDeleteDialogProps) {
return (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete persona?</AlertDialogTitle>
<AlertDialogDescription>
{persona
? `Delete ${persona.displayName}. Existing agents keep their copied settings, but this template will no longer be available for new deployments.`
: "Delete this persona."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={() => {
if (persona) {
onConfirm(persona);
}
}}
type="button"
variant="destructive"
>
Delete
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -1,992 +0,0 @@
import * as React from "react";
import { ChevronDown, RefreshCw, Upload } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type {
AcpRuntimeCatalogEntry,
CreatePersonaInput,
UpdatePersonaInput,
} from "@/shared/api/types";
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import { Dialog } from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { AgentCreationPreview } from "./AgentCreationPreview";
import { PersonaDropdownField } from "./PersonaDropdownField";
import type { EnvVarsValue } from "./EnvVarsEditor";
import { PersonaAdvancedFields } from "./PersonaAdvancedFields";
import { PersonaModelField } from "./PersonaModelField";
import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField";
import {
getImportButtonLabel,
getImportButtonTone,
getImportErrorLabel,
IMPORT_ERROR_VISIBILITY_MS,
} from "./personaDialogImportState";
import {
canSubmitPersonaDialog,
formatPersonaNamePoolText,
parsePersonaNamePoolText,
} from "./personaDialogState";
import {
getAdvancedEnvVars,
hasAdvancedEnvVars,
hasText,
} from "./personaDialogEnvVars";
import {
AUTO_MODEL_DROPDOWN_VALUE,
AUTO_PROVIDER_DROPDOWN_VALUE,
CUSTOM_MODEL_DROPDOWN_VALUE,
CUSTOM_PROVIDER_DROPDOWN_VALUE,
formatRuntimeOptionLabel,
getDefaultLlmProviderLabel,
getDefaultPersonaRuntime,
getModelSelectValue,
getPersonaModelOptions,
getPersonaProviderOptions,
getProviderApiKeyConfig,
getProviderApiKeyEnvVar,
getRuntimePersonaModelOptions,
hasPersonaModelOption,
NO_RUNTIME_DROPDOWN_VALUE,
providerRequiresExplicitModel,
runtimeSupportsLlmProviderSelection,
type PersonaDropdownOption,
PERSONA_FIELD_CONTROL_CLASS,
PERSONA_FIELD_SHELL_CLASS,
PERSONA_LABEL_OPTIONAL_CLASS,
shouldClearKnownModelForSelectionScope,
sortPersonaRuntimes,
} from "./personaDialogPickers";
import { shouldClearModelForRuntimeChange } from "./personaRuntimeModel";
import {
MODEL_DISCOVERY_LOADING_VALUE,
usePersonaModelDiscovery,
} from "./usePersonaModelDiscovery";
type PersonaDialogProps = {
open: boolean;
title: string;
description: string;
submitLabel: string;
initialValues: CreatePersonaInput | UpdatePersonaInput | null;
error: Error | null;
isPending: boolean;
isImportPending?: boolean;
runtimes: AcpRuntimeCatalogEntry[];
runtimesLoading?: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (input: CreatePersonaInput | UpdatePersonaInput) => Promise<void>;
onImportUpdateFile?: (
personaId: string,
fileBytes: number[],
fileName: string,
) => Promise<void>;
};
const ADVANCED_FIELDS_MOTION_TRANSITION = {
duration: 0.18,
ease: [0.23, 1, 0.32, 1],
} as const;
export function PersonaDialog({
open,
title,
description,
submitLabel,
initialValues,
error,
isPending,
isImportPending = false,
runtimes,
runtimesLoading = false,
onOpenChange,
onSubmit,
onImportUpdateFile,
}: PersonaDialogProps) {
const [displayName, setDisplayName] = React.useState("");
const [avatarUrl, setAvatarUrl] = React.useState("");
const [systemPrompt, setSystemPrompt] = React.useState("");
const [runtime, setRuntime] = React.useState("");
const [model, setModel] = React.useState("");
const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false);
const [provider, setProvider] = React.useState("");
const [isCustomProviderEditing, setIsCustomProviderEditing] =
React.useState(false);
const [namePoolText, setNamePoolText] = React.useState("");
const [envVars, setEnvVars] = React.useState<EnvVarsValue>({});
const [showAdvancedFields, setShowAdvancedFields] = React.useState(false);
const [isAvatarUploadPending, setIsAvatarUploadPending] =
React.useState(false);
const [isImportingUpdate, setIsImportingUpdate] = React.useState(false);
const [importErrorMessage, setImportErrorMessage] = React.useState<
string | null
>(null);
const [isWindowFileDragOver, setIsWindowFileDragOver] = React.useState(false);
const isEditMode = Boolean(initialValues && "id" in initialValues);
const editPersonaId =
isEditMode && initialValues && "id" in initialValues
? initialValues.id
: null;
const canImportPersonaUpdate = isEditMode && Boolean(onImportUpdateFile);
const defaultRuntime = React.useMemo(
() => getDefaultPersonaRuntime(runtimes),
[runtimes],
);
const shouldReduceMotion = useReducedMotion();
const initialModelProviderEditableWithoutRuntime = Boolean(
initialValues &&
"id" in initialValues &&
!hasText(initialValues.runtime) &&
(hasText(initialValues.model) || hasText(initialValues.provider)),
);
React.useEffect(() => {
if (!open || !initialValues) {
return;
}
setDisplayName(initialValues.displayName);
setAvatarUrl(initialValues.avatarUrl ?? "");
setSystemPrompt(initialValues.systemPrompt);
setRuntime(initialValues.runtime ?? "");
setModel(initialValues.model ?? "");
setIsCustomModelEditing(false);
setProvider(initialValues.provider ?? "");
setIsCustomProviderEditing(false);
const nextNamePoolText =
"namePool" in initialValues
? formatPersonaNamePoolText(initialValues.namePool)
: "";
const nextEnvVars =
"envVars" in initialValues ? (initialValues.envVars ?? {}) : {};
const managedApiKeyEnvVar = getProviderApiKeyEnvVar(
initialValues.provider ?? "",
);
setNamePoolText(nextNamePoolText);
setEnvVars(nextEnvVars);
setShowAdvancedFields(
nextNamePoolText.trim().length > 0 ||
hasAdvancedEnvVars(nextEnvVars, managedApiKeyEnvVar),
);
setIsAvatarUploadPending(false);
setImportErrorMessage(null);
setIsImportingUpdate(false);
}, [initialValues, open]);
React.useEffect(() => {
if (
!open ||
!initialValues ||
"id" in initialValues ||
initialValues.runtime?.trim() ||
runtimesLoading ||
runtime.trim().length > 0 ||
defaultRuntime === null
) {
return;
}
setRuntime(defaultRuntime.id);
}, [defaultRuntime, initialValues, open, runtime, runtimesLoading]);
React.useEffect(() => {
if (!open || !canImportPersonaUpdate) {
setIsWindowFileDragOver(false);
return;
}
let dragDepth = 0;
function isFileDrag(event: DragEvent): boolean {
return Array.from(event.dataTransfer?.types ?? []).includes("Files");
}
function handleWindowDragEnter(event: DragEvent) {
if (!isFileDrag(event)) {
return;
}
dragDepth += 1;
setIsWindowFileDragOver(true);
}
function handleWindowDragOver(event: DragEvent) {
if (!isFileDrag(event)) {
return;
}
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "copy";
}
setIsWindowFileDragOver(true);
}
function handleWindowDragLeave(event: DragEvent) {
if (!isFileDrag(event)) {
return;
}
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) {
setIsWindowFileDragOver(false);
}
}
function handleWindowDrop(event: DragEvent) {
if (!isFileDrag(event)) {
return;
}
event.preventDefault();
dragDepth = 0;
setIsWindowFileDragOver(false);
}
window.addEventListener("dragenter", handleWindowDragEnter);
window.addEventListener("dragover", handleWindowDragOver);
window.addEventListener("dragleave", handleWindowDragLeave);
window.addEventListener("drop", handleWindowDrop);
return () => {
window.removeEventListener("dragenter", handleWindowDragEnter);
window.removeEventListener("dragover", handleWindowDragOver);
window.removeEventListener("dragleave", handleWindowDragLeave);
window.removeEventListener("drop", handleWindowDrop);
};
}, [canImportPersonaUpdate, open]);
React.useEffect(() => {
if (!open || !importErrorMessage) {
return;
}
const timeout = window.setTimeout(() => {
setImportErrorMessage(null);
}, IMPORT_ERROR_VISIBILITY_MS);
return () => {
window.clearTimeout(timeout);
};
}, [importErrorMessage, open]);
async function handleImportUpdateSelection(
fileBytes: number[],
fileName: string,
) {
if (!editPersonaId || !onImportUpdateFile) {
return;
}
setImportErrorMessage(null);
setIsImportingUpdate(true);
try {
await onImportUpdateFile(editPersonaId, fileBytes, fileName);
} catch (error) {
setImportErrorMessage(
getImportErrorLabel(error instanceof Error ? error.message : null),
);
} finally {
setIsImportingUpdate(false);
}
}
const {
fileInputRef: importFileInputRef,
isDragOver: isImportDragOver,
dropHandlers: importDropHandlers,
handleFileChange: handleImportFileChange,
openFilePicker: openImportFilePicker,
} = useFileImportZone({
onImportFile: (fileBytes, fileName) => {
void handleImportUpdateSelection(fileBytes, fileName);
},
});
function handleOpenChange(next: boolean) {
if (!next) {
setDisplayName("");
setAvatarUrl("");
setSystemPrompt("");
setRuntime("");
setModel("");
setIsCustomModelEditing(false);
setProvider("");
setIsCustomProviderEditing(false);
setNamePoolText("");
setEnvVars({});
setShowAdvancedFields(false);
setIsAvatarUploadPending(false);
setImportErrorMessage(null);
setIsImportingUpdate(false);
setIsWindowFileDragOver(false);
}
onOpenChange(next);
}
async function handleSubmit() {
if (
!initialValues ||
!canSubmitPersonaDialog({ displayName, isPending }) ||
isAvatarUploadPending
) {
return;
}
const trimmedRuntime = runtime.trim();
const previousRuntime = initialValues.runtime?.trim() ?? "";
const modelProviderEditableWithoutRuntime =
initialModelProviderEditableWithoutRuntime && trimmedRuntime.length === 0;
const llmProviderVisibleForSubmit =
(trimmedRuntime.length > 0 &&
runtimeSupportsLlmProviderSelection(trimmedRuntime)) ||
modelProviderEditableWithoutRuntime;
const shouldPreserveHiddenModelProvider =
"id" in initialValues &&
previousRuntime.length === 0 &&
trimmedRuntime.length === 0 &&
!modelProviderEditableWithoutRuntime;
const namePool = parsePersonaNamePoolText(namePoolText);
const namePoolInput =
namePool.length > 0
? namePool
: "namePool" in initialValues
? []
: undefined;
const baseInput = {
displayName: displayName.trim(),
avatarUrl: avatarUrl.trim() || undefined,
systemPrompt: systemPrompt,
runtime: trimmedRuntime || undefined,
model:
trimmedRuntime || modelProviderEditableWithoutRuntime
? model.trim() || undefined
: shouldPreserveHiddenModelProvider
? initialValues.model
: undefined,
provider: llmProviderVisibleForSubmit
? provider.trim() || undefined
: shouldPreserveHiddenModelProvider
? initialValues.provider
: undefined,
namePool: namePoolInput,
envVars,
};
if ("id" in initialValues) {
await onSubmit({
id: initialValues.id,
...baseInput,
});
return;
}
await onSubmit(baseInput);
}
function handleSubmitForm(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
void handleSubmit();
}
const importButtonTone = getImportButtonTone({
isWindowFileDragOver,
isImportDragOver,
importErrorMessage,
});
const importButtonLabel = getImportButtonLabel({
isWindowFileDragOver,
isImportDragOver,
importErrorMessage,
});
const selectedRuntime = runtimes.find((p) => p.id === runtime);
const blankRuntimeModelProviderEditable =
initialModelProviderEditableWithoutRuntime && runtime.trim().length === 0;
const runtimeCanChooseLlmProvider =
runtimeSupportsLlmProviderSelection(runtime) ||
blankRuntimeModelProviderEditable;
const llmProviderFieldVisible =
(runtime.trim().length > 0 && runtimeCanChooseLlmProvider) ||
blankRuntimeModelProviderEditable;
const providerForModelScope = llmProviderFieldVisible ? provider : "";
const trimmedProvider = provider.trim();
const providerApiKeyConfig =
llmProviderFieldVisible && !isCustomProviderEditing
? getProviderApiKeyConfig(trimmedProvider)
: null;
const providerApiKeyValue = providerApiKeyConfig
? (envVars[providerApiKeyConfig.envVar] ?? "")
: "";
const providerApiKeyFieldVisible =
llmProviderFieldVisible && providerApiKeyConfig !== null;
const modelFieldVisible =
runtime.trim().length > 0 || blankRuntimeModelProviderEditable;
const isExplicitModelRequired =
modelFieldVisible && providerRequiresExplicitModel(providerForModelScope);
const isCreateMode = Boolean(initialValues && !("id" in initialValues));
const selectedRuntimeIsAvailable =
runtime.trim().length === 0 ||
selectedRuntime?.availability === "available";
const canSubmit =
canSubmitPersonaDialog({ displayName, isPending }) &&
(!isCreateMode || runtime.trim().length > 0) &&
(!isCreateMode || selectedRuntimeIsAvailable) &&
(!isExplicitModelRequired || model.trim().length > 0) &&
!isAvatarUploadPending;
const {
discoveredModelOptions,
modelDiscoveryLoading,
modelDiscoveryStatus,
} = usePersonaModelDiscovery({
envVars,
isCustomProviderEditing,
modelFieldVisible,
open,
provider: providerForModelScope,
selectedRuntime,
});
const staticModelOptions = getPersonaModelOptions(
runtime,
providerForModelScope,
);
const runtimeModelOptions = getRuntimePersonaModelOptions(runtime);
const modelOptions = discoveredModelOptions ?? staticModelOptions;
const isModelCustom = !hasPersonaModelOption(
discoveredModelOptions ?? runtimeModelOptions,
model,
);
const modelSelectValue = getModelSelectValue({
isCustomModelEditing,
isModelCustom,
model,
});
const showCustomModelInput =
modelFieldVisible && (isCustomModelEditing || isModelCustom);
const providerOptions = getPersonaProviderOptions(
providerForModelScope,
runtime,
);
const defaultLlmProviderLabel = getDefaultLlmProviderLabel(runtime);
const providerSelectValue = isCustomProviderEditing
? CUSTOM_PROVIDER_DROPDOWN_VALUE
: trimmedProvider || AUTO_PROVIDER_DROPDOWN_VALUE;
const showCustomProviderInput =
llmProviderFieldVisible && isCustomProviderEditing;
const advancedEnvVars = getAdvancedEnvVars(
envVars,
providerApiKeyConfig?.envVar ?? null,
);
const runtimeDropdownValue = runtime.trim() || NO_RUNTIME_DROPDOWN_VALUE;
const sortedRuntimes = React.useMemo(
() => sortPersonaRuntimes(runtimes),
[runtimes],
);
const blankRuntimeOptionLabel = runtimesLoading
? "Loading providers..."
: isCreateMode
? "Choose a provider"
: "No preference (use app default)";
const runtimeDropdownOptions: PersonaDropdownOption[] = [
...(!isCreateMode
? [
{
label: blankRuntimeOptionLabel,
value: NO_RUNTIME_DROPDOWN_VALUE,
},
]
: []),
...sortedRuntimes.map((candidate) => ({
disabled: isCreateMode && candidate.availability !== "available",
label: `${formatRuntimeOptionLabel(candidate)}${
isCreateMode && candidate.id === defaultRuntime?.id ? " (default)" : ""
}`,
value: candidate.id,
})),
];
if (
runtime.trim().length > 0 &&
!runtimeDropdownOptions.some((option) => option.value === runtime)
) {
runtimeDropdownOptions.push({
label: `${runtime.trim()} (current)`,
value: runtime.trim(),
});
}
const providerDropdownOptions: PersonaDropdownOption[] = [
...providerOptions.map((option) => ({
label: option.label,
value: option.id || AUTO_PROVIDER_DROPDOWN_VALUE,
})),
{ label: "Custom provider...", value: CUSTOM_PROVIDER_DROPDOWN_VALUE },
];
const modelDropdownOptions: PersonaDropdownOption[] = [
...modelOptions.map((option) => ({
label: option.label,
value: option.id || AUTO_MODEL_DROPDOWN_VALUE,
})),
...(modelDiscoveryLoading && discoveredModelOptions === null
? [
{
disabled: true,
label: "Loading models...",
value: MODEL_DISCOVERY_LOADING_VALUE,
},
]
: []),
{ label: "Custom model...", value: CUSTOM_MODEL_DROPDOWN_VALUE },
];
const previewLabel = displayName.trim() || "Agent name";
const previewAvatarUrl = avatarUrl.trim() || null;
const runtimeWarning =
selectedRuntime && selectedRuntime.availability !== "available" ? (
<p className="text-xs text-warning">
{selectedRuntime.availability === "adapter_missing"
? `${selectedRuntime.label} CLI is installed but the ACP adapter is missing.`
: selectedRuntime.availability === "cli_missing"
? `${selectedRuntime.label} ACP adapter is installed but the CLI is missing.`
: `${selectedRuntime.label} is not installed.`}{" "}
Visit Settings &gt; Doctor to set it up.
</p>
) : null;
const advancedFieldsTransition = shouldReduceMotion
? { duration: 0 }
: ADVANCED_FIELDS_MOTION_TRANSITION;
React.useEffect(() => {
if (
!open ||
!modelFieldVisible ||
isCustomModelEditing ||
!shouldClearKnownModelForSelectionScope({
model,
provider: providerForModelScope,
runtime,
})
) {
return;
}
setModel("");
setIsCustomModelEditing(false);
}, [
isCustomModelEditing,
model,
modelFieldVisible,
open,
providerForModelScope,
runtime,
]);
function updateProviderApiKey(envKey: string, value: string) {
setEnvVars((current) => {
if ((current[envKey] ?? "") === value) {
return current;
}
const next = { ...current };
if (value.length > 0) {
next[envKey] = value;
} else {
delete next[envKey];
}
return next;
});
}
function removeEnvVar(envKey: string) {
setEnvVars((current) => {
if (!(envKey in current)) {
return current;
}
const next = { ...current };
delete next[envKey];
return next;
});
}
function clearManagedProviderApiKeyWhenLeaving(
previousProvider: string,
nextProvider: string,
) {
const previousEnvVar = getProviderApiKeyEnvVar(previousProvider);
const nextEnvVar = getProviderApiKeyEnvVar(nextProvider);
if (previousEnvVar && previousEnvVar !== nextEnvVar) {
removeEnvVar(previousEnvVar);
}
}
function handleProviderApiKeyChange(value: string) {
if (!providerApiKeyConfig) {
return;
}
updateProviderApiKey(providerApiKeyConfig.envVar, value);
}
function handleAdvancedEnvVarsChange(nextAdvancedEnvVars: EnvVarsValue) {
setEnvVars((current) => {
const managedEnvVar = providerApiKeyConfig?.envVar ?? null;
if (!managedEnvVar || !(managedEnvVar in current)) {
return nextAdvancedEnvVars;
}
return {
...nextAdvancedEnvVars,
[managedEnvVar]: current[managedEnvVar],
};
});
}
function handleRuntimeDropdownChange(nextValue: string) {
const nextRuntime =
nextValue === NO_RUNTIME_DROPDOWN_VALUE ? "" : nextValue;
const previousRuntime = runtime;
const nextRuntimeCanChooseLlmProvider =
nextRuntime.trim().length > 0 &&
runtimeSupportsLlmProviderSelection(nextRuntime);
setRuntime(nextRuntime);
if (
shouldClearModelForRuntimeChange(previousRuntime, nextRuntime) ||
shouldClearKnownModelForSelectionScope({
model,
provider,
runtime: nextRuntime,
})
) {
setModel("");
setIsCustomModelEditing(false);
}
if (!nextRuntimeCanChooseLlmProvider) {
clearManagedProviderApiKeyWhenLeaving(provider, "");
setIsCustomModelEditing(false);
setIsCustomProviderEditing(false);
setProvider("");
}
}
function handleProviderDropdownChange(nextValue: string) {
if (nextValue === CUSTOM_PROVIDER_DROPDOWN_VALUE) {
clearManagedProviderApiKeyWhenLeaving(provider, "");
setIsCustomProviderEditing(true);
setProvider("");
return;
}
const nextProvider =
nextValue === AUTO_PROVIDER_DROPDOWN_VALUE ? "" : nextValue;
clearManagedProviderApiKeyWhenLeaving(provider, nextProvider);
setIsCustomProviderEditing(false);
setProvider(nextProvider);
const requiredEnvVar = getProviderApiKeyEnvVar(nextProvider);
if (requiredEnvVar && !envVars[requiredEnvVar]?.trim()) {
setModel("");
setIsCustomModelEditing(false);
}
if (
!isCustomModelEditing &&
shouldClearKnownModelForSelectionScope({
model,
provider: nextProvider,
runtime,
})
) {
setModel("");
setIsCustomModelEditing(false);
}
}
function handleModelDropdownChange(nextValue: string) {
if (nextValue === CUSTOM_MODEL_DROPDOWN_VALUE) {
setIsCustomModelEditing(true);
if (!isModelCustom) {
setModel("");
}
return;
}
setIsCustomModelEditing(false);
setModel(nextValue === AUTO_MODEL_DROPDOWN_VALUE ? "" : nextValue);
}
return (
<Dialog
onOpenChange={(nextOpen) => {
if (!nextOpen && (isPending || isAvatarUploadPending)) return;
handleOpenChange(nextOpen);
}}
open={open}
>
<ChooserDialogContent
className="max-w-3xl border-0"
contentClassName="pt-3"
data-testid="persona-dialog"
description={description}
footerClassName="border-t-0 pt-0"
headerClassName="pb-2"
title={title}
footer={
<div className="flex w-full items-center justify-between gap-3">
<div className="flex min-h-9 items-center">
{canImportPersonaUpdate ? (
<>
<input
accept=".md,.json,.png,.zip"
className="hidden"
onChange={handleImportFileChange}
ref={importFileInputRef}
type="file"
/>
<button
className={cn(
"inline-flex h-9 items-center gap-2 rounded-md border px-3 text-xs font-medium transition-colors",
importButtonTone === "drag"
? "border-dashed border-primary/70 bg-primary/10 text-primary"
: importButtonTone === "error"
? "border-destructive/40 bg-destructive/10 text-destructive hover:bg-destructive/15"
: "border-border bg-background text-muted-foreground hover:bg-muted hover:text-foreground",
)}
disabled={isPending || isImportPending || isImportingUpdate}
type="button"
{...importDropHandlers}
onClick={openImportFilePicker}
title={
importButtonTone === "error"
? importButtonLabel
: undefined
}
>
<Upload className="h-4 w-4" />
<span className="max-w-[16rem] truncate">
{importButtonLabel}
</span>
{isImportingUpdate ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : null}
</button>
</>
) : null}
</div>
<div className="flex items-center gap-2">
<Button
disabled={isPending || isAvatarUploadPending}
onClick={() => handleOpenChange(false)}
type="button"
variant="outline"
>
Cancel
</Button>
<Button
data-testid="persona-dialog-submit"
disabled={!canSubmit}
form="persona-dialog-form"
type="submit"
>
{isPending
? "Saving..."
: isAvatarUploadPending
? "Uploading..."
: submitLabel}
</Button>
</div>
</div>
}
>
<form
className="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)]"
id="persona-dialog-form"
onSubmit={handleSubmitForm}
>
<AgentCreationPreview
avatarUrl={previewAvatarUrl}
disabled={isPending || isAvatarUploadPending}
label={previewLabel}
onClearAvatar={() => setAvatarUrl("")}
onUploadPendingChange={setIsAvatarUploadPending}
onSelectAvatar={setAvatarUrl}
/>
<div className="space-y-5">
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="persona-display-name"
>
Agent name
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={isPending}
id="persona-display-name"
onChange={(event) => setDisplayName(event.target.value)}
placeholder="Fizz"
value={displayName}
/>
</div>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="persona-system-prompt"
>
Agent instruction
</label>
<div className={PERSONA_FIELD_SHELL_CLASS}>
<Textarea
className={cn(
"min-h-40 resize-y px-3 py-3 leading-5",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={isPending}
id="persona-system-prompt"
onChange={(event) => setSystemPrompt(event.target.value)}
placeholder="Describe what this agent should do."
value={systemPrompt}
/>
</div>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="persona-runtime"
>
Provider
</label>
<PersonaDropdownField
disabled={isPending || runtimesLoading}
id="persona-runtime"
onValueChange={handleRuntimeDropdownChange}
options={runtimeDropdownOptions}
placeholder={blankRuntimeOptionLabel}
value={runtimeDropdownValue}
/>
{runtimeWarning}
</div>
{llmProviderFieldVisible ? (
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="persona-llm-provider"
>
LLM provider
<span className={PERSONA_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<PersonaDropdownField
disabled={isPending}
id="persona-llm-provider"
onValueChange={handleProviderDropdownChange}
options={providerDropdownOptions}
placeholder={defaultLlmProviderLabel}
value={providerSelectValue}
/>
{showCustomProviderInput ? (
<div
className={cn(
"mt-2 flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
aria-label="Custom provider ID"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={isPending}
id="persona-custom-provider"
onChange={(event) => setProvider(event.target.value)}
placeholder="Custom provider ID"
value={provider}
/>
</div>
) : null}
{providerApiKeyFieldVisible && providerApiKeyConfig ? (
<PersonaProviderApiKeyField
config={providerApiKeyConfig}
disabled={isPending}
onChange={handleProviderApiKeyChange}
value={providerApiKeyValue}
/>
) : null}
</div>
) : null}
<AnimatePresence initial={false}>
{modelFieldVisible ? (
<PersonaModelField
disabled={isPending}
isExplicitModelRequired={isExplicitModelRequired}
model={model}
modelDiscoveryStatus={modelDiscoveryStatus}
modelDropdownOptions={modelDropdownOptions}
modelSelectValue={modelSelectValue}
onCustomModelChange={setModel}
onModelValueChange={handleModelDropdownChange}
showCustomModelInput={showCustomModelInput}
transition={advancedFieldsTransition}
/>
) : null}
</AnimatePresence>
<div className="space-y-3">
<button
aria-expanded={showAdvancedFields}
className="inline-flex h-9 items-center gap-1.5 text-sm font-medium text-foreground transition-colors hover:text-foreground/80 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setShowAdvancedFields((current) => !current)}
type="button"
>
<span>Advanced</span>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-150 ease-out",
showAdvancedFields && "rotate-180",
)}
/>
</button>
<AnimatePresence initial={false}>
{showAdvancedFields ? (
<motion.div
animate={{ height: "auto", opacity: 1, scale: 1 }}
className="origin-top overflow-hidden"
exit={{ height: 0, opacity: 0, scale: 0.98 }}
initial={{ height: 0, opacity: 0, scale: 0.98 }}
key="persona-advanced-fields"
transition={advancedFieldsTransition}
>
<PersonaAdvancedFields
disabled={isPending}
envVars={advancedEnvVars}
namePoolText={namePoolText}
onEnvVarsChange={handleAdvancedEnvVarsChange}
onNamePoolTextChange={setNamePoolText}
/>
</motion.div>
) : null}
</AnimatePresence>
</div>
{error ? (
<p className="text-sm text-destructive">{error.message}</p>
) : null}
</div>
</form>
</ChooserDialogContent>
</Dialog>
);
}

View File

@@ -1,62 +0,0 @@
import { Info } from "lucide-react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { AgentPersona } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { promptPreview } from "@/shared/lib/promptPreview";
import { Badge } from "@/shared/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
type PersonaIdentityProps = {
className?: string;
persona: AgentPersona;
showBuiltInBadge?: boolean;
showPromptTooltip?: boolean;
};
export function PersonaIdentity({
className,
persona,
showBuiltInBadge = persona.isBuiltIn,
showPromptTooltip = true,
}: PersonaIdentityProps) {
const preview = promptPreview(persona.systemPrompt);
return (
<div className={cn("min-w-0 flex-1", className)}>
<div className="flex min-w-0 items-center gap-2.5">
<ProfileAvatar
avatarUrl={persona.avatarUrl}
className="h-8 w-8 rounded-lg text-xs"
label={persona.displayName}
/>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<p className="truncate text-sm font-semibold tracking-tight">
{persona.displayName}
</p>
{showBuiltInBadge ? (
<Badge variant="secondary">Built-in</Badge>
) : null}
{showPromptTooltip && preview ? (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="View system prompt"
className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
type="button"
>
<Info className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<p>{preview}</p>
</TooltipContent>
</Tooltip>
) : null}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,271 +0,0 @@
import * as React from "react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { ParsedPersonaPreview } from "@/shared/api/tauriPersonas";
import type { AgentPersona } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import {
buildPersonaImportPlan,
hasAnyPersonaImportChanges,
} from "./personaImportPlan";
import {
getFieldPreview,
getFieldSecondaryText,
} from "./personaImportUpdateState";
type PersonaImportUpdateDialogProps = {
open: boolean;
persona: AgentPersona | null;
preview: ParsedPersonaPreview | null;
fileName: string;
isPending: boolean;
onClear: () => void;
onOpenChange: (open: boolean) => void;
onApply: (input: { selectedFields: string[] }) => Promise<void>;
};
export function PersonaImportUpdateDialog({
open,
persona,
preview,
fileName,
isPending,
onClear,
onOpenChange,
onApply,
}: PersonaImportUpdateDialogProps) {
const [selectedFields, setSelectedFields] = React.useState<Set<string>>(
new Set(),
);
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const plan = React.useMemo(() => {
if (!persona || !preview) {
return null;
}
return buildPersonaImportPlan({ persona, preview });
}, [persona, preview]);
React.useEffect(() => {
if (!open) {
return;
}
setErrorMessage(null);
setSelectedFields(new Set());
}, [open]);
React.useEffect(() => {
if (!open || !plan) {
return;
}
setSelectedFields(new Set(plan.fields.map((field) => field.field)));
}, [open, plan]);
function toggleField(field: string, checked: boolean) {
setSelectedFields((current) => {
const next = new Set(current);
if (checked) {
next.add(field);
} else {
next.delete(field);
}
return next;
});
}
async function runApply() {
setErrorMessage(null);
try {
await onApply({
selectedFields: Array.from(selectedFields),
});
} catch (error) {
setErrorMessage(
error instanceof Error
? error.message
: "Failed to apply imported persona update.",
);
}
}
const hasChanges = hasAnyPersonaImportChanges(plan);
const selectedCount = selectedFields.size;
function renderLineChangeSummary(
addedLines: number,
removedLines: number,
emphasize = true,
) {
const addedClass = emphasize
? addedLines > 0
? "text-status-added"
: "text-muted-foreground"
: "text-muted-foreground";
const separatorClass = "text-muted-foreground";
const removedClass = emphasize
? removedLines > 0
? "text-status-deleted"
: "text-muted-foreground"
: "text-muted-foreground";
const opacityClass = emphasize ? "opacity-100" : "opacity-50";
return (
<p
className={`shrink-0 text-xs font-medium tabular-nums transition-opacity ${opacityClass}`}
>
<span className={addedClass}>+{addedLines}</span>
<span className={separatorClass}> / </span>
<span className={removedClass}>-{removedLines}</span>
</p>
);
}
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="flex max-h-[85vh] max-w-3xl flex-col overflow-hidden p-0">
<DialogHeader className="shrink-0 space-y-1 border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>Import persona</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 rounded-md border border-border/60 bg-card/80 px-3 py-2">
<p className="min-w-0 truncate text-sm font-medium">
{fileName || "Imported file"}
</p>
<button
aria-label="Clear import"
className="shrink-0 text-sm text-primary underline-offset-4 hover:underline"
disabled={isPending}
onClick={onClear}
type="button"
>
Clear
</button>
</div>
{preview && plan ? (
<div className="space-y-4">
{hasChanges ? (
<div className="space-y-1">
<p className="text-sm font-medium">
Fields that will be updated{" "}
<span className="font-bold">
({selectedCount}/{plan.fields.length})
</span>
</p>
<div className="space-y-1">
{plan.fields.map((fieldChange) => {
const shouldUpdate = selectedFields.has(
fieldChange.field,
);
const previewText = getFieldSecondaryText(
shouldUpdate,
getFieldPreview(
fieldChange.importedValue,
`No ${fieldChange.label.toLowerCase()} in import.`,
),
getFieldPreview(
fieldChange.existingValue,
`No current ${fieldChange.label.toLowerCase()}.`,
),
);
return (
<div
className="flex items-center gap-3 rounded-lg border border-border/60 bg-card/80 px-3 py-2.5"
key={fieldChange.field}
>
<Checkbox
checked={shouldUpdate}
disabled={isPending}
onCheckedChange={(checked) =>
toggleField(fieldChange.field, Boolean(checked))
}
/>
{fieldChange.field === "avatarUrl" ? (
<ProfileAvatar
avatarUrl={
shouldUpdate
? fieldChange.importedValue || null
: fieldChange.existingValue || null
}
className="h-8 w-8 rounded-lg text-xs"
label={persona?.displayName ?? ""}
/>
) : null}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold tracking-tight">
{fieldChange.label}
</p>
<p
className={`truncate text-xs ${
shouldUpdate
? "text-foreground"
: "text-muted-foreground"
}`}
>
{previewText}
</p>
</div>
{renderLineChangeSummary(
fieldChange.addedLines,
fieldChange.removedLines,
shouldUpdate,
)}
</div>
);
})}
</div>
</div>
) : (
<div className="rounded-lg border border-dashed border-border/60 bg-card/60 px-4 py-10 text-center">
<p className="text-sm font-semibold tracking-tight text-muted-foreground">
no changes
</p>
</div>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">
No import preview is available.
</p>
)}
{errorMessage ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{errorMessage}
</p>
) : null}
</div>
</div>
<div className="flex shrink-0 justify-end gap-2 border-t border-border/60 px-6 py-4">
<Button
onClick={() => onOpenChange(false)}
size="sm"
type="button"
variant="outline"
>
Cancel
</Button>
<Button
disabled={!preview || isPending || selectedCount === 0}
onClick={() => void runApply()}
size="sm"
type="button"
>
Apply update
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,57 +0,0 @@
import { Upload } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { CreateNewButton } from "./CreateNewButton";
import { personaLibraryCopy } from "./personaLibraryCopy";
type PersonaLibraryEntryPointsProps = {
canChooseCatalog?: boolean;
isPending: boolean;
layout: "header" | "empty";
onCreate: () => void;
onChooseCatalog?: () => void;
onImport?: () => void;
};
export function PersonaLibraryEntryPoints({
canChooseCatalog = false,
isPending,
layout,
onCreate,
onChooseCatalog,
onImport,
}: PersonaLibraryEntryPointsProps) {
const isHeader = layout === "header";
const chooseVariant = isHeader ? "outline" : "default";
const createVariant = isHeader ? "default" : "outline";
return (
<>
{canChooseCatalog && onChooseCatalog ? (
<Button
data-testid="open-persona-catalog"
disabled={isPending}
onClick={onChooseCatalog}
size="sm"
type="button"
variant={chooseVariant}
>
{personaLibraryCopy.chooseFromCatalog}
</Button>
) : null}
<CreateNewButton
disabled={isPending}
label={personaLibraryCopy.createNew}
onClick={onCreate}
variant={createVariant}
/>
{!isHeader && onImport ? (
<Button onClick={onImport} size="sm" type="button" variant="outline">
<Upload className="h-4 w-4" />
{personaLibraryCopy.import}
</Button>
) : null}
</>
);
}

View File

@@ -1,106 +0,0 @@
import { BookUser, Download, X } from "lucide-react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { AgentPersona } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Separator } from "@/shared/ui/separator";
import { Switch } from "@/shared/ui/switch";
import { PersonaAddedBy } from "./PersonaAddedBy";
type PersonaShareDialogProps = {
isCatalogVisible: boolean;
isPending: boolean;
onCatalogVisibilityChange: (visible: boolean) => void;
onExport: () => void;
onOpenChange: (open: boolean) => void;
open: boolean;
persona: AgentPersona;
};
export function PersonaShareDialog({
isCatalogVisible,
isPending,
onCatalogVisibilityChange,
onExport,
onOpenChange,
open,
persona,
}: PersonaShareDialogProps) {
const switchId = `persona-share-catalog-${persona.id}`;
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
aria-describedby={undefined}
className="max-w-md"
data-testid="persona-share-dialog"
showCloseButton={false}
>
<DialogHeader className="space-y-0">
<div className="flex items-center justify-between gap-4">
<DialogTitle>Catalog options</DialogTitle>
<div className="flex items-center gap-2">
<Button
data-testid="persona-share-export"
disabled={isPending}
onClick={onExport}
size="sm"
type="button"
variant="outline"
>
<Download className="h-4 w-4" />
Export
</Button>
<DialogClose className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent hover:text-accent-foreground focus:outline-hidden focus:ring-1 focus:ring-ring">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</div>
</DialogHeader>
<div>
<div className="flex min-w-0 items-center gap-3 py-4">
<ProfileAvatar
avatarUrl={persona.avatarUrl}
className="h-10 w-10 text-xs"
label={persona.displayName}
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold leading-snug">
{persona.displayName}
</p>
{persona.isBuiltIn ? null : <PersonaAddedBy className="mt-0.5" />}
</div>
</div>
<Separator />
<div className="flex items-center justify-between gap-4 py-4">
<div className="flex min-w-0 items-center gap-2">
<BookUser className="h-4 w-4 shrink-0 text-muted-foreground" />
<label className="text-sm font-medium" htmlFor={switchId}>
Show in my catalog
</label>
</div>
<Switch
checked={isCatalogVisible}
data-testid="persona-share-show-in-catalog"
disabled={persona.isBuiltIn || isPending}
id={switchId}
onCheckedChange={onCatalogVisibilityChange}
/>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -2,9 +2,17 @@ import * as React from "react";
import { RefreshCw, Upload } from "lucide-react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import {
memberFromAgent,
memberFromPersona,
splitMemberKeys,
teamMemberKeys,
type TeamCardMember,
} from "@/features/agents/lib/teamMembers";
import type {
AgentPersona,
CreateTeamInput,
ManagedAgent,
UpdateTeamInput,
} from "@/shared/api/types";
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
@@ -21,7 +29,6 @@ import {
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { personaCatalogCopy } from "./personaLibraryCopy";
import { RemoveMembersConfirmDialog } from "./RemoveMembersConfirmDialog";
import {
getImportButtonLabel,
@@ -29,12 +36,12 @@ import {
getImportErrorLabel,
IMPORT_ERROR_VISIBILITY_MS,
} from "./teamDialogImportState";
import {
copySelectedPersonaIds,
countMissingPersonaIds,
filterAvailablePersonaIds,
orderPersonasByInitiallySelected,
} from "./teamDialogSelection";
export type RemovedTeamMemberRef = {
kind: "agent" | "persona";
id: string;
displayName: string;
};
type TeamDialogProps = {
open: boolean;
@@ -42,13 +49,14 @@ type TeamDialogProps = {
description: string;
submitLabel: string;
initialValues: CreateTeamInput | UpdateTeamInput | null;
agents: ManagedAgent[];
personas: AgentPersona[];
error: Error | null;
isPending: boolean;
isImportPending?: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (input: CreateTeamInput | UpdateTeamInput) => Promise<void>;
onDeleteRemovedPersonas?: (personaIds: string[]) => Promise<void>;
onDeleteRemovedMembers?: (members: RemovedTeamMemberRef[]) => Promise<void>;
onImportUpdateFile?: (
teamId: string,
fileBytes: number[],
@@ -62,24 +70,23 @@ export function TeamDialog({
description,
submitLabel,
initialValues,
agents,
personas,
error,
isPending,
isImportPending = false,
onOpenChange,
onSubmit,
onDeleteRemovedPersonas,
onDeleteRemovedMembers,
onImportUpdateFile,
}: TeamDialogProps) {
const [name, setName] = React.useState("");
const [teamDescription, setTeamDescription] = React.useState("");
const [selectedPersonaIds, setSelectedPersonaIds] = React.useState<string[]>(
const [selectedMemberKeys, setSelectedMemberKeys] = React.useState<string[]>(
[],
);
const [
initialSelectedPersonaIdsForSort,
setInitialSelectedPersonaIdsForSort,
] = React.useState<string[]>([]);
const [initialSelectedKeysForSort, setInitialSelectedKeysForSort] =
React.useState<string[]>([]);
const [isImportingUpdate, setIsImportingUpdate] = React.useState(false);
const [importErrorMessage, setImportErrorMessage] = React.useState<
string | null
@@ -92,25 +99,41 @@ export function TeamDialog({
: null;
const canImportTeamUpdate = isEditMode && Boolean(onImportUpdateFile);
const [isWindowFileDragOver, setIsWindowFileDragOver] = React.useState(false);
const missingInitialPersonaCount = React.useMemo(() => {
// Pickable members: every managed agent, plus the persona-backed members
// this team already carries (pack teams) so they stay visible/toggleable.
const memberOptions = React.useMemo<TeamCardMember[]>(() => {
const initialPersonaIds = new Set(initialValues?.personaIds ?? []);
const personaOptions = personas
.filter((persona) => initialPersonaIds.has(persona.id))
.map(memberFromPersona);
const agentOptions = agents.map(memberFromAgent);
return [...personaOptions, ...agentOptions];
}, [agents, initialValues, personas]);
const memberOptionsByKey = React.useMemo(
() => new Map(memberOptions.map((option) => [option.key, option])),
[memberOptions],
);
const missingInitialMemberCount = React.useMemo(() => {
if (!initialValues) {
return 0;
}
return countMissingPersonaIds(initialValues.personaIds, personas);
}, [initialValues, personas]);
return teamMemberKeys(initialValues).filter(
(key) => !memberOptionsByKey.has(key),
).length;
}, [initialValues, memberOptionsByKey]);
React.useEffect(() => {
if (!open || !initialValues) {
return;
}
const keys = teamMemberKeys(initialValues);
setName(initialValues.name);
setTeamDescription(initialValues.description ?? "");
setSelectedPersonaIds(copySelectedPersonaIds(initialValues.personaIds));
setInitialSelectedPersonaIdsForSort(
copySelectedPersonaIds(initialValues.personaIds),
);
setSelectedMemberKeys(keys);
setInitialSelectedKeysForSort(keys);
setImportErrorMessage(null);
setIsImportingUpdate(false);
}, [initialValues, open]);
@@ -227,8 +250,8 @@ export function TeamDialog({
if (!next) {
setName("");
setTeamDescription("");
setSelectedPersonaIds([]);
setInitialSelectedPersonaIdsForSort([]);
setSelectedMemberKeys([]);
setInitialSelectedKeysForSort([]);
setImportErrorMessage(null);
setIsImportingUpdate(false);
setIsWindowFileDragOver(false);
@@ -238,35 +261,38 @@ export function TeamDialog({
onOpenChange(next);
}
function togglePersona(personaId: string) {
setSelectedPersonaIds((current) =>
current.includes(personaId)
? current.filter((id) => id !== personaId)
: [...current, personaId],
function toggleMember(key: string) {
setSelectedMemberKeys((current) =>
current.includes(key)
? current.filter((candidate) => candidate !== key)
: [...current, key],
);
}
const removedPersonaIds = React.useMemo(() => {
const removedMembers = React.useMemo<RemovedTeamMemberRef[]>(() => {
if (!isEditMode || !initialValues || !("id" in initialValues)) return [];
const currentSet = new Set(selectedPersonaIds);
return initialValues.personaIds.filter(
(id) => !currentSet.has(id) && personas.some((p) => p.id === id),
);
}, [isEditMode, initialValues, selectedPersonaIds, personas]);
const removedPersonaNames = React.useMemo(
() =>
removedPersonaIds
.map((id) => personas.find((p) => p.id === id)?.displayName)
.filter(Boolean),
[removedPersonaIds, personas],
);
const currentSet = new Set(selectedMemberKeys);
return teamMemberKeys(initialValues)
.filter((key) => !currentSet.has(key))
.map((key) => memberOptionsByKey.get(key))
.filter((option): option is TeamCardMember => option != null)
.map((option) => ({
kind: option.kind,
id: option.id,
displayName: option.displayName,
}));
}, [isEditMode, initialValues, memberOptionsByKey, selectedMemberKeys]);
function buildSubmitInput(): CreateTeamInput | UpdateTeamInput {
const availableKeys = selectedMemberKeys.filter((key) =>
memberOptionsByKey.has(key),
);
const { personaIds, agentPubkeys } = splitMemberKeys(availableKeys);
const baseInput = {
name,
description: teamDescription.trim() || undefined,
personaIds: filterAvailablePersonaIds(selectedPersonaIds, personas),
personaIds,
agentPubkeys,
};
if (initialValues && "id" in initialValues) {
@@ -278,7 +304,7 @@ export function TeamDialog({
async function handleSubmit() {
if (!initialValues) return;
if (removedPersonaIds.length > 0 && isEditMode && onDeleteRemovedPersonas) {
if (removedMembers.length > 0 && isEditMode && onDeleteRemovedMembers) {
setConfirmRemovalOpen(true);
return;
}
@@ -294,8 +320,8 @@ export function TeamDialog({
async function handleSubmitDeleteAgents() {
setConfirmRemovalOpen(false);
await onSubmit(buildSubmitInput());
if (onDeleteRemovedPersonas && removedPersonaIds.length > 0) {
await onDeleteRemovedPersonas(removedPersonaIds);
if (onDeleteRemovedMembers && removedMembers.length > 0) {
await onDeleteRemovedMembers(removedMembers);
}
}
@@ -309,14 +335,19 @@ export function TeamDialog({
isImportDragOver,
importErrorMessage,
});
const orderedPersonas = React.useMemo(
() =>
orderPersonasByInitiallySelected(
personas,
initialSelectedPersonaIdsForSort,
),
[initialSelectedPersonaIdsForSort, personas],
);
const orderedMembers = React.useMemo(() => {
const selectedKeys = new Set(initialSelectedKeysForSort);
const selected: TeamCardMember[] = [];
const unselected: TeamCardMember[] = [];
for (const option of memberOptions) {
if (selectedKeys.has(option.key)) {
selected.push(option);
} else {
unselected.push(option);
}
}
return [...selected, ...unselected];
}, [initialSelectedKeysForSort, memberOptions]);
return (
<>
@@ -363,42 +394,42 @@ export function TeamDialog({
</div>
<div className="space-y-2">
<span className="text-sm font-medium">Personas</span>
<span className="text-sm font-medium">Agents</span>
<p className="text-xs text-muted-foreground">
Select the personas to include in this team.
Select the agents to include in this team.
</p>
{missingInitialPersonaCount > 0 ? (
{missingInitialMemberCount > 0 ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
This team references {missingInitialPersonaCount} persona
{missingInitialPersonaCount === 1 ? "" : "s"} that{" "}
{missingInitialPersonaCount === 1 ? "is" : "are"} no longer
in My Agents. Save to remove them, or add them back to My
Agents first.
This team references {missingInitialMemberCount} agent
{missingInitialMemberCount === 1 ? "" : "s"} that{" "}
{missingInitialMemberCount === 1 ? "is" : "are"} no longer
available on this device. Save to remove them.
</p>
) : null}
{personas.length === 0 ? (
{memberOptions.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
{personaCatalogCopy.teamEmptyState}
No agents yet. Create an agent first, then group agents into
a team.
</p>
) : (
<div
className="max-h-60 space-y-1 overflow-y-auto rounded-lg border border-border/70 p-2"
role="listbox"
aria-label="Personas"
aria-label="Agents"
aria-multiselectable="true"
>
{orderedPersonas.map((persona) => {
const isSelected = selectedPersonaIds.includes(
persona.id,
{orderedMembers.map((member) => {
const isSelected = selectedMemberKeys.includes(
member.key,
);
return (
<div
className="flex cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-muted/50"
key={persona.id}
key={member.key}
onClick={() => {
if (!isPending) {
togglePersona(persona.id);
toggleMember(member.key);
}
}}
onKeyDown={(event) => {
@@ -407,7 +438,7 @@ export function TeamDialog({
(event.key === "Enter" || event.key === " ")
) {
event.preventDefault();
togglePersona(persona.id);
toggleMember(member.key);
}
}}
role="option"
@@ -421,13 +452,13 @@ export function TeamDialog({
tabIndex={-1}
/>
<ProfileAvatar
avatarUrl={persona.avatarUrl}
avatarUrl={member.avatarUrl}
className="h-6 w-6 text-2xs"
label={persona.displayName}
label={member.displayName}
/>
<span className="text-sm">{persona.displayName}</span>
{persona.isBuiltIn ? (
<Badge variant="secondary">Built-in</Badge>
<span className="text-sm">{member.displayName}</span>
{member.kind === "persona" ? (
<Badge variant="secondary">Team pack</Badge>
) : null}
</div>
);
@@ -499,7 +530,7 @@ export function TeamDialog({
<Button
disabled={
name.trim().length === 0 ||
selectedPersonaIds.length === 0 ||
selectedMemberKeys.length === 0 ||
isPending
}
onClick={() => void handleSubmit()}
@@ -518,7 +549,7 @@ export function TeamDialog({
open={confirmRemovalOpen}
onOpenChange={setConfirmRemovalOpen}
isPending={isPending}
memberNames={removedPersonaNames as string[]}
memberNames={removedMembers.map((member) => member.displayName)}
onKeepAgents={() => void handleSubmitKeepAgents()}
onRemoveAgents={() => void handleSubmitDeleteAgents()}
/>

View File

@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
import { Info, Link, Users } from "lucide-react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { AgentPersona } from "@/shared/api/types";
import type { TeamCardMember } from "@/features/agents/lib/teamMembers";
import { Card } from "@/shared/ui/card";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { formatAgentModelLabel } from "@/features/agents/lib/formatAgentModelLabel";
@@ -15,7 +15,7 @@ type TeamIdentityCardProps = {
description?: string | null;
isSymlink?: boolean;
memberCount: number;
personas: AgentPersona[];
members: TeamCardMember[];
sourceDir?: string | null;
symlinkTarget?: string | null;
teamName: string;
@@ -31,13 +31,13 @@ export function TeamIdentityCard({
description,
isSymlink = false,
memberCount,
personas,
members,
sourceDir,
symlinkTarget,
teamName,
version,
}: TeamIdentityCardProps) {
const footerModelLabel = getTeamFooterModelLabel(personas);
const footerModelLabel = getTeamFooterModelLabel(members);
const trimmedDescription = description?.trim();
return (
@@ -86,7 +86,7 @@ export function TeamIdentityCard({
<TeamAvatarRow
memberCount={memberCount}
personas={personas}
members={members}
teamName={teamName}
/>
@@ -106,17 +106,17 @@ export function TeamIdentityCard({
function TeamAvatarRow({
memberCount,
personas,
members,
teamName,
}: {
memberCount: number;
personas: AgentPersona[];
members: TeamCardMember[];
teamName: string;
}) {
const visiblePersonas = personas.slice(0, MAX_VISIBLE_MEMBER_AVATARS);
const overflowCount = Math.max(0, memberCount - visiblePersonas.length);
const visibleMembers = members.slice(0, MAX_VISIBLE_MEMBER_AVATARS);
const overflowCount = Math.max(0, memberCount - visibleMembers.length);
if (visiblePersonas.length === 0 && overflowCount === 0) {
if (visibleMembers.length === 0 && overflowCount === 0) {
return (
<div className="absolute inset-x-4 top-0 bottom-12 flex items-center justify-center">
<div className="flex h-24 w-24 items-center justify-center rounded-full border border-border/65 bg-background/80 text-muted-foreground shadow-xs">
@@ -133,8 +133,8 @@ function TeamAvatarRow({
className="flex max-w-full items-center justify-center gap-2 px-4"
role="img"
>
{visiblePersonas.map((persona, index) => (
<TeamAvatarItem index={index} key={persona.id} persona={persona} />
{visibleMembers.map((member, index) => (
<TeamAvatarItem index={index} key={member.key} member={member} />
))}
{overflowCount > 0 ? (
<span className="flex h-14 w-14 items-center justify-center rounded-full border-[3px] border-background bg-card text-sm font-semibold text-muted-foreground shadow-sm">
@@ -148,12 +148,12 @@ function TeamAvatarRow({
function TeamAvatarItem({
index,
persona,
member,
}: {
index: number;
persona: AgentPersona;
member: TeamCardMember;
}) {
const avatarUrl = persona.avatarUrl?.trim() ?? null;
const avatarUrl = member.avatarUrl?.trim() ?? null;
return (
<div className="h-14 w-14" data-team-member-avatar="avatar">
@@ -162,13 +162,13 @@ function TeamAvatarItem({
avatarUrl={avatarUrl}
className="h-full w-full border-[3px] border-background bg-muted shadow-sm"
iconClassName="h-6 w-6"
label={persona.displayName}
testId={`team-member-avatar-${persona.id}`}
label={member.displayName}
testId={`team-member-avatar-${member.id}`}
/>
) : (
<IdentityInitialsAvatar
colorIndex={index}
label={persona.displayName}
label={member.displayName}
size={56}
/>
)}
@@ -176,9 +176,9 @@ function TeamAvatarItem({
);
}
function getTeamFooterModelLabel(personas: AgentPersona[]) {
const modelLabels = personas
.map((persona) => formatAgentModelLabel(persona.model))
function getTeamFooterModelLabel(members: TeamCardMember[]) {
const modelLabels = members
.map((member) => formatAgentModelLabel(member.model))
.filter((model): model is string => Boolean(model));
if (modelLabels.length === 0) return "Auto";

View File

@@ -2,8 +2,9 @@ import * as React from "react";
import { Users } from "lucide-react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { useAvailableAcpRuntimes } from "@/features/agents/hooks";
import type { ParsedTeamPreview } from "@/shared/api/tauriTeams";
import { createPersona } from "@/shared/api/tauriPersonas";
import { createManagedAgent } from "@/shared/api/tauri";
import { promptPreview } from "@/shared/lib/promptPreview";
import {
ImportStatusIcon,
@@ -26,7 +27,7 @@ type TeamImportDialogProps = {
onComplete: (
teamName: string,
teamDescription: string | null,
personaIds: string[],
agentPubkeys: string[],
) => void;
};
@@ -45,8 +46,9 @@ export function TeamImportDialog({
Map<number, ImportItemStatus>
>(new Map());
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const runtimesQuery = useAvailableAcpRuntimes({ enabled: open });
const personas = preview?.personas ?? [];
const members = preview?.personas ?? [];
React.useEffect(() => {
if (!open) {
@@ -59,7 +61,16 @@ export function TeamImportDialog({
}, [open]);
async function handleImport() {
if (!preview || personas.length === 0) {
if (!preview || members.length === 0) {
return;
}
const runtime = (runtimesQuery.data ?? [])[0] ?? null;
if (!runtime) {
setStatus("error");
setErrorMessage(
"No available agent runtime found. Visit Settings > Doctor to set one up.",
);
return;
}
@@ -67,16 +78,16 @@ export function TeamImportDialog({
setErrorMessage(null);
const initialStatuses = new Map<number, ImportItemStatus>();
for (let i = 0; i < personas.length; i++) {
for (let i = 0; i < members.length; i++) {
initialStatuses.set(i, "pending");
}
setItemStatuses(new Map(initialStatuses));
const personaIds: string[] = [];
const agentPubkeys: string[] = [];
let completed = 0;
for (let i = 0; i < personas.length; i++) {
const persona = personas[i];
for (let i = 0; i < members.length; i++) {
const member = members[i];
setItemStatuses((prev) => {
const next = new Map(prev);
@@ -85,12 +96,21 @@ export function TeamImportDialog({
});
try {
const created = await createPersona({
displayName: persona.display_name,
systemPrompt: persona.system_prompt,
avatarUrl: persona.avatar_url ?? undefined,
// Imported team members are created stopped so a large team never
// spawns a fleet of processes at once.
const created = await createManagedAgent({
name: member.display_name,
acpCommand: "buzz-acp",
agentCommand: runtime.command,
agentArgs: runtime.defaultArgs,
mcpCommand: runtime.mcpCommand ?? "",
systemPrompt: member.system_prompt || undefined,
avatarUrl: member.avatar_url ?? undefined,
spawnAfterCreate: false,
startOnAppLaunch: false,
backend: { type: "local" },
});
personaIds.push(created.id);
agentPubkeys.push(created.agent.pubkey);
completed += 1;
setImportedCount(completed);
setItemStatuses((prev) => {
@@ -106,21 +126,21 @@ export function TeamImportDialog({
});
setStatus("error");
setErrorMessage(
`Imported ${completed} of ${personas.length} personas. Failed on '${persona.display_name}': ${error instanceof Error ? error.message : String(error)}. Already-imported personas are saved.`,
`Imported ${completed} of ${members.length} agents. Failed on '${member.display_name}': ${error instanceof Error ? error.message : String(error)}. Already-imported agents are saved.`,
);
return;
}
}
setStatus("done");
onComplete(preview.name, preview.description, personaIds);
onComplete(preview.name, preview.description, agentPubkeys);
}
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>Import Team</DialogTitle>
<DialogTitle>Import team</DialogTitle>
<DialogDescription>
Preview the team from {fileName || "file"} before importing.
</DialogDescription>
@@ -142,20 +162,19 @@ export function TeamImportDialog({
) : null}
</div>
<span className="text-xs text-muted-foreground">
{personas.length}{" "}
{personas.length === 1 ? "persona" : "personas"}
{members.length} {members.length === 1 ? "agent" : "agents"}
</span>
</div>
<div className="space-y-1">
<p className="text-sm font-medium">Personas to import</p>
<p className="text-sm font-medium">Agents to import</p>
<p className="text-xs text-muted-foreground">
Each persona will be created, then grouped into a new team.
Each agent will be created, then grouped into a new team.
</p>
</div>
<div className="space-y-1">
{personas.map((persona, index) => (
{members.map((persona, index) => (
<div
className="flex items-center gap-3 rounded-lg border border-border/60 bg-card/80 px-3 py-2.5"
// biome-ignore lint/suspicious/noArrayIndexKey: static list from imported JSON file, never reordered
@@ -200,7 +219,7 @@ export function TeamImportDialog({
<Button
disabled={
!preview ||
personas.length === 0 ||
members.length === 0 ||
status === "importing" ||
status === "done" ||
status === "error"
@@ -210,8 +229,8 @@ export function TeamImportDialog({
type="button"
>
{status === "importing"
? `Importing ${importedCount}/${personas.length}...`
: `Import team (${personas.length} personas)`}
? `Importing ${importedCount}/${members.length}...`
: `Import team (${members.length} agent${members.length === 1 ? "" : "s"})`}
</Button>
</div>
</DialogContent>

View File

@@ -11,7 +11,7 @@ import {
DialogTitle,
} from "@/shared/ui/dialog";
import type { ParsedTeamPreview } from "@/shared/api/tauriTeams";
import type { AgentPersona, AgentTeam } from "@/shared/api/types";
import type { AgentPersona, AgentTeam, ManagedAgent } from "@/shared/api/types";
import { RemoveMembersConfirmDialog } from "./RemoveMembersConfirmDialog";
import { buildTeamImportPlan } from "./teamImportPlan";
import {
@@ -24,17 +24,17 @@ type TeamImportUpdateDialogProps = {
open: boolean;
team: AgentTeam | null;
personas: AgentPersona[];
agents: ManagedAgent[];
preview: ParsedTeamPreview | null;
fileName: string;
isPending: boolean;
onClear: () => void;
onOpenChange: (open: boolean) => void;
onApply: (input: {
personas: AgentPersona[];
updateTeamInfo: boolean;
selectedUpdatedPersonaIds: string[];
selectedUpdatedMemberIds: string[];
selectedNewMemberIndexes: number[];
missingPersonaIdsToRemove: string[];
missingMemberIdsToRemove: string[];
deleteRemovedAgents: boolean;
}) => Promise<void>;
};
@@ -43,6 +43,7 @@ export function TeamImportUpdateDialog({
open,
team,
personas,
agents,
preview,
fileName,
isPending,
@@ -64,8 +65,8 @@ export function TeamImportUpdateDialog({
if (!team || !preview) {
return null;
}
return buildTeamImportPlan({ team, personas, preview });
}, [team, personas, preview]);
return buildTeamImportPlan({ team, personas, agents, preview });
}, [agents, team, personas, preview]);
React.useEffect(() => {
if (!open) {
@@ -132,11 +133,10 @@ export function TeamImportUpdateDialog({
setErrorMessage(null);
try {
await onApply({
personas,
updateTeamInfo,
selectedUpdatedPersonaIds: Array.from(selectedUpdatedPersonaIds),
selectedUpdatedMemberIds: Array.from(selectedUpdatedPersonaIds),
selectedNewMemberIndexes: Array.from(selectedNewMemberIndexes),
missingPersonaIdsToRemove: Array.from(missingPersonaIdsToRemove),
missingMemberIdsToRemove: Array.from(missingPersonaIdsToRemove),
deleteRemovedAgents,
});
setConfirmRemovalOpen(false);
@@ -590,11 +590,11 @@ export function TeamImportUpdateDialog({
</div>
)}
{plan.unresolvedPersonaIds.length > 0 ? (
{plan.unresolvedMemberIds.length > 0 ? (
<p className="rounded-lg border border-amber-300/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800">
This team currently references{" "}
{plan.unresolvedPersonaIds.length} missing member
{plan.unresolvedPersonaIds.length === 1 ? "" : "s"} and
{plan.unresolvedMemberIds.length} missing member
{plan.unresolvedMemberIds.length === 1 ? "" : "s"} and
they cannot be updated by import.
</p>
) : null}

View File

@@ -9,8 +9,8 @@ import {
Trash2,
} from "lucide-react";
import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas";
import type { AgentPersona, AgentTeam } from "@/shared/api/types";
import { resolveTeamMembers } from "@/features/agents/lib/teamMembers";
import type { AgentPersona, AgentTeam, ManagedAgent } from "@/shared/api/types";
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
import {
DropdownMenu,
@@ -29,6 +29,7 @@ const TEAM_CARD_GRID_CLASS = `${TEAM_CARD_COLUMN_CLASS} grid grid-cols-[repeat(a
type TeamsSectionProps = {
teams: AgentTeam[];
personas: AgentPersona[];
agents: ManagedAgent[];
error: Error | null;
isLoading: boolean;
isPending: boolean;
@@ -47,6 +48,7 @@ type TeamsSectionProps = {
export function TeamsSection({
teams,
personas,
agents,
error,
isLoading,
isPending,
@@ -96,7 +98,7 @@ export function TeamsSection({
<div>
<h3 className="text-sm font-semibold tracking-tight">Teams</h3>
<p className="text-sm text-secondary-foreground/75">
Saved groups from My Agents that you can add to a channel together.
Saved groups of agents that you can add to a channel together.
</p>
</div>
</div>
@@ -124,9 +126,9 @@ export function TeamsSection({
{!isLoading ? (
<div className={TEAM_CARD_GRID_CLASS}>
{teams.map((team) => {
const resolution = resolveTeamPersonas(team, personas);
const missingPersonaCount = resolution.missingPersonaCount;
const hasMissingPersonas = resolution.hasMissingPersonas;
const resolution = resolveTeamMembers(team, personas, agents);
const missingMemberCount = resolution.missingMemberCount;
const hasMissingMembers = resolution.hasMissingMembers;
return (
<TeamIdentityCard
@@ -146,7 +148,7 @@ export function TeamsSection({
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
disabled={isPending || hasMissingPersonas}
disabled={isPending || hasMissingMembers}
onClick={() => onAddToChannel(team)}
>
<Rocket className="h-4 w-4" />
@@ -161,14 +163,14 @@ export function TeamsSection({
Edit
</DropdownMenuItem>
<DropdownMenuItem
disabled={isPending || hasMissingPersonas}
disabled={isPending || hasMissingMembers}
onClick={() => onDuplicate(team)}
>
<CopyPlus className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
disabled={isPending || hasMissingPersonas}
disabled={isPending || hasMissingMembers}
onClick={() => onExport(team)}
>
<Download className="h-4 w-4" />
@@ -208,20 +210,20 @@ export function TeamsSection({
description={team.description}
isSymlink={team.isSymlink}
key={team.id}
memberCount={team.personaIds.length}
personas={resolution.resolvedPersonas}
memberCount={resolution.memberCount}
members={resolution.resolvedMembers}
sourceDir={team.sourceDir}
symlinkTarget={team.symlinkTarget}
teamName={team.name}
version={team.version}
>
{hasMissingPersonas ? (
{hasMissingMembers ? (
<p className="border-t border-destructive/20 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{missingPersonaCount} persona
{missingPersonaCount === 1 ? "" : "s"} in this team{" "}
{missingPersonaCount === 1 ? "is" : "are"} no longer in your
My Agents. Edit the team to repair it before deploying or
exporting.
{missingMemberCount} agent
{missingMemberCount === 1 ? "" : "s"} in this team{" "}
{missingMemberCount === 1 ? "is" : "are"} no longer
available on this device. Edit the team to repair it before
deploying or exporting.
</p>
) : null}
</TeamIdentityCard>

View File

@@ -1,17 +1,11 @@
import * as React from "react";
import {
ChevronDown,
ChevronRight,
Ellipsis,
OctagonX,
Trash2,
} from "lucide-react";
import type * as React from "react";
import { Ellipsis, OctagonX, Trash2 } from "lucide-react";
import { formatAgentModelLabel } from "@/features/agents/lib/formatAgentModelLabel";
import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
import { useUserProfileQuery } from "@/features/profile/hooks";
import type { AgentPersona, ManagedAgent } from "@/shared/api/types";
import type { ManagedAgent } from "@/shared/api/types";
import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext";
import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
@@ -20,15 +14,13 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { IdentityCardSkeleton } from "@/shared/ui/identity-card-skeleton";
import { AgentActionsMenu } from "./AgentActionsMenu";
import { AgentIdentityCard } from "./AgentIdentityCard";
import { AgentRuntimeAvatarControl } from "./AgentRuntimeAvatarControl";
import { CreateIdentityCard } from "./CreateIdentityCard";
import { PersonaActionsMenu } from "./PersonaActionsMenu";
import { buildUnifiedGroups, pickProfileAgent } from "./unifiedAgentGroups";
type UnifiedAgentsSectionProps = {
actionErrorMessage: string | null;
@@ -38,7 +30,6 @@ type UnifiedAgentsSectionProps = {
isActionPending: boolean;
isAgentsLoading: boolean;
startingAgentPubkey: string | null;
startingPersonaIds: ReadonlySet<string>;
onBulkRemoveStopped: () => void;
onBulkStopRunning: () => void;
onCreateAgent: () => void;
@@ -46,24 +37,13 @@ type UnifiedAgentsSectionProps = {
pubkey: string,
options?: ProfilePanelOpenOptions,
) => void;
onOpenPersonaProfile: (persona: AgentPersona) => void;
onStartAgent: (pubkey: string) => void;
onStartPersona: (persona: AgentPersona) => void;
canChooseCatalog: boolean;
personas: AgentPersona[];
personasError: Error | null;
personaFeedbackErrorMessage: string | null;
personaFeedbackNoticeMessage: string | null;
isPersonasLoading: boolean;
isPersonasPending: boolean;
onCreatePersona: () => void;
onChooseCatalog: () => void;
onDuplicatePersona: (persona: AgentPersona) => void;
onEditPersona: (persona: AgentPersona) => void;
onSharePersona: (persona: AgentPersona) => void;
onDeactivatePersona: (persona: AgentPersona) => void;
onDeletePersona: (persona: AgentPersona) => void;
onImportPersonaFile: (fileBytes: number[], fileName: string) => void;
onAddAgentToChannel: (agent: ManagedAgent) => void;
onDeleteAgent: (agent: ManagedAgent) => void;
onDuplicateAgent: (agent: ManagedAgent) => void;
onEditAgent: (agent: ManagedAgent) => void;
onExportAgent: (agent: ManagedAgent) => void;
onImportAgentFile: (fileBytes: number[], fileName: string) => void;
};
const AGENT_CARD_COLUMN_CLASS = "w-full";
@@ -78,29 +58,17 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
isActionPending,
isAgentsLoading,
startingAgentPubkey,
startingPersonaIds,
onBulkRemoveStopped,
onBulkStopRunning,
onCreateAgent,
onOpenAgentProfile,
onOpenPersonaProfile,
onStartAgent,
onStartPersona,
canChooseCatalog,
personas,
personasError,
personaFeedbackErrorMessage,
personaFeedbackNoticeMessage,
isPersonasLoading,
isPersonasPending,
onCreatePersona,
onChooseCatalog,
onDuplicatePersona,
onEditPersona,
onSharePersona,
onDeactivatePersona,
onDeletePersona,
onImportPersonaFile,
onAddAgentToChannel,
onDeleteAgent,
onDuplicateAgent,
onEditAgent,
onExportAgent,
onImportAgentFile,
} = props;
const runningCount = agents.filter((agent) =>
@@ -109,43 +77,10 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
const stoppedCount = agents.filter(
(agent) => agent.status === "stopped" || agent.status === "not_deployed",
).length;
const { groups, ungrouped, unknown } = React.useMemo(
() => buildUnifiedGroups(personas, agents),
[personas, agents],
);
const additionalPersonaAgents = React.useMemo(() => {
const additional: ManagedAgent[] = [];
for (const group of groups) {
const primary = pickProfileAgent(group.agents);
for (const agent of group.agents) {
if (primary?.pubkey !== agent.pubkey) {
additional.push(agent);
}
}
}
return additional;
}, [groups]);
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
const {
fileInputRef,
isDragOver,
dropHandlers,
handleFileChange,
openFilePicker,
} = useFileImportZone({ onImportFile: onImportPersonaFile });
function toggle(key: string) {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
const { fileInputRef, isDragOver, dropHandlers, handleFileChange } =
useFileImportZone({ onImportFile: onImportAgentFile });
useFeedbackToasts(actionNoticeMessage, actionErrorMessage);
useFeedbackToasts(personaFeedbackNoticeMessage, personaFeedbackErrorMessage);
const isLoading = isAgentsLoading || isPersonasLoading;
return (
<section
@@ -156,7 +91,8 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
{isDragOver ? (
<div className="pointer-events-none absolute -inset-1 z-10 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-background/80 backdrop-blur-sm">
<p className="text-sm font-medium text-primary">
Drop .persona.md, .persona.json, .persona.png, or .zip to import
Drop agent files (.persona.md, .persona.json, .persona.png, or .zip)
to import
</p>
</div>
) : null}
@@ -172,85 +108,38 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
onBulkStopRunning={onBulkStopRunning}
/>
{isLoading ? <LoadingSkeleton /> : null}
{isAgentsLoading ? <LoadingSkeleton /> : null}
{!isLoading ? (
{!isAgentsLoading ? (
<div className="space-y-3" data-testid="unified-agents-groups">
<div className={AGENT_CARD_GRID_CLASS}>
{groups.map((group) => {
const profileAgent = pickProfileAgent(group.agents);
return (
<AgentPersonaCard
actions={
<PersonaActionsMenu
isActionPending={isActionPending}
isPending={isPersonasPending}
persona={group.persona}
onDeactivate={onDeactivatePersona}
onDelete={onDeletePersona}
onDuplicate={onDuplicatePersona}
onEdit={onEditPersona}
onShare={onSharePersona}
/>
}
agent={profileAgent}
key={group.persona.id}
persona={group.persona}
startingAgentPubkey={startingAgentPubkey}
startingPersonaIds={startingPersonaIds}
onOpenAgentProfile={onOpenAgentProfile}
onOpenPersonaProfile={onOpenPersonaProfile}
onStartAgent={onStartAgent}
onStartPersona={onStartPersona}
/>
);
})}
<NewAgentCard
canChooseCatalog={canChooseCatalog}
isPersonasPending={isPersonasPending}
openFilePicker={openFilePicker}
onChooseCatalog={onChooseCatalog}
onCreateAgent={onCreateAgent}
onCreatePersona={onCreatePersona}
{agents.map((agent) => (
<AgentCard
actions={
<AgentActionsMenu
agent={agent}
disabled={isActionPending}
onAddToChannel={onAddAgentToChannel}
onDelete={onDeleteAgent}
onDuplicate={onDuplicateAgent}
onEdit={onEditAgent}
onExport={onExportAgent}
/>
}
agent={agent}
key={agent.pubkey}
startingAgentPubkey={startingAgentPubkey}
onOpenAgentProfile={onOpenAgentProfile}
onStartAgent={onStartAgent}
/>
))}
<CreateIdentityCard
ariaLabel="New agent"
dataTestId="new-agent-card"
label="New agent"
onClick={onCreateAgent}
/>
</div>
{additionalPersonaAgents.length > 0 ? (
<CollapsibleAgentGroup
agents={additionalPersonaAgents}
collapsed={collapsed}
groupKey="__additional_persona_agents__"
label="Additional agent instances"
startingAgentPubkey={startingAgentPubkey}
onToggle={toggle}
onOpenAgentProfile={onOpenAgentProfile}
onStartAgent={onStartAgent}
/>
) : null}
{unknown.length > 0 ? (
<CollapsibleAgentGroup
agents={unknown}
collapsed={collapsed}
groupKey="__unknown__"
label="Unknown Persona"
startingAgentPubkey={startingAgentPubkey}
onToggle={toggle}
onOpenAgentProfile={onOpenAgentProfile}
onStartAgent={onStartAgent}
/>
) : null}
{ungrouped.length > 0 ? (
<CollapsibleAgentGroup
agents={ungrouped}
collapsed={collapsed}
groupKey="__ungrouped__"
label="Custom agents"
startingAgentPubkey={startingAgentPubkey}
onToggle={toggle}
onOpenAgentProfile={onOpenAgentProfile}
onStartAgent={onStartAgent}
/>
) : null}
</div>
) : null}
@@ -261,109 +150,18 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
{agentsError.message}
</p>
) : null}
{personasError ? (
<p
className={`${AGENT_CARD_COLUMN_CLASS} rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive`}
>
{personasError.message}
</p>
) : null}
</section>
);
}
function AgentPersonaCard({
function AgentCard({
actions,
agent,
persona,
startingAgentPubkey,
startingPersonaIds,
onOpenAgentProfile,
onOpenPersonaProfile,
onStartAgent,
onStartPersona,
}: {
actions?: React.ReactNode;
agent: ManagedAgent | undefined;
persona: AgentPersona;
startingAgentPubkey: string | null;
startingPersonaIds: ReadonlySet<string>;
onOpenAgentProfile: (
pubkey: string,
options?: ProfilePanelOpenOptions,
) => void;
onOpenPersonaProfile: (persona: AgentPersona) => void;
onStartAgent: (pubkey: string) => void;
onStartPersona: (persona: AgentPersona) => void;
}) {
const title = persona.displayName;
const modelLabel = formatAgentModelLabel(agent?.model ?? persona.model);
const isActive = agent ? isManagedAgentActive(agent) : false;
const profileQuery = useUserProfileQuery(agent?.pubkey);
const avatarUrl = agent
? firstAvatarUrl(profileQuery.data?.avatarUrl, persona.avatarUrl)
: persona.avatarUrl;
const friendlyError = agent
? friendlyAgentLastError(agent.lastError)?.copy
: null;
const opensRuntimeTab = Boolean(agent && friendlyError && !isActive);
return (
<AgentIdentityCard
actions={actions}
ariaLabel={`${title} agent profile`}
avatar={
agent ? (
<AgentRuntimeAvatarControl
activeTestId={`agent-runtime-active-${agent.pubkey}`}
avatarUrl={avatarUrl}
errorLabel={friendlyError}
errorTestId={`agent-runtime-error-${agent.pubkey}`}
isActive={isActive}
isStarting={startingAgentPubkey === agent.pubkey}
label={title}
startTestId={`agent-runtime-start-${agent.pubkey}`}
onOpenError={() => {
onOpenAgentProfile(agent.pubkey, { tab: "runtime" });
}}
onStart={() => onStartAgent(agent.pubkey)}
/>
) : (
<AgentRuntimeAvatarControl
activeTestId={`persona-runtime-active-${persona.id}`}
avatarUrl={avatarUrl}
isActive={false}
isStarting={startingPersonaIds.has(persona.id)}
label={title}
startTestId={`persona-runtime-start-${persona.id}`}
onStart={() => onStartPersona(persona)}
/>
)
}
avatarUrl={avatarUrl}
dataTestId={`persona-agent-row-${persona.id}`}
label={title}
modelLabel={agent && isActive ? modelLabel : null}
onClick={() => {
if (agent) {
onOpenAgentProfile(
agent.pubkey,
opensRuntimeTab ? { tab: "runtime" } : undefined,
);
return;
}
onOpenPersonaProfile(persona);
}}
/>
);
}
function StandaloneAgentCard({
agent,
startingAgentPubkey,
onOpenAgentProfile,
onStartAgent,
}: {
agent: ManagedAgent;
startingAgentPubkey: string | null;
onOpenAgentProfile: (
@@ -374,17 +172,22 @@ function StandaloneAgentCard({
}) {
const title = agent.name;
const profileQuery = useUserProfileQuery(agent.pubkey);
const avatarUrl = firstAvatarUrl(
profileQuery.data?.avatarUrl,
agent.avatarUrl,
);
const friendlyError = friendlyAgentLastError(agent.lastError)?.copy;
const isActive = isManagedAgentActive(agent);
const opensRuntimeTab = Boolean(friendlyError && !isActive);
return (
<AgentIdentityCard
actions={actions}
ariaLabel={`${title} agent profile`}
avatar={
<AgentRuntimeAvatarControl
activeTestId={`agent-runtime-active-${agent.pubkey}`}
avatarUrl={profileQuery.data?.avatarUrl}
avatarUrl={avatarUrl}
errorLabel={friendlyError}
errorTestId={`agent-runtime-error-${agent.pubkey}`}
isActive={isActive}
@@ -397,7 +200,7 @@ function StandaloneAgentCard({
onStart={() => onStartAgent(agent.pubkey)}
/>
}
avatarUrl={profileQuery.data?.avatarUrl}
avatarUrl={avatarUrl}
dataTestId={`managed-agent-${agent.pubkey}`}
label={title}
modelLabel={isActive ? formatAgentModelLabel(agent.model) : null}
@@ -495,60 +298,6 @@ function SectionHeader({
);
}
function NewAgentCard({
canChooseCatalog,
isPersonasPending,
openFilePicker,
onChooseCatalog,
onCreateAgent,
onCreatePersona,
}: {
canChooseCatalog: boolean;
isPersonasPending: boolean;
openFilePicker: () => void;
onChooseCatalog: () => void;
onCreateAgent: () => void;
onCreatePersona: () => void;
}) {
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<CreateIdentityCard
ariaLabel="New agent"
dataTestId="new-agent-card"
label="New agent"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
disabled={isPersonasPending}
onClick={onCreatePersona}
>
New agent
</DropdownMenuItem>
{canChooseCatalog ? (
<DropdownMenuItem
disabled={isPersonasPending}
onClick={onChooseCatalog}
>
Choose from catalog
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onCreateAgent}>
Custom agent
</DropdownMenuItem>
<DropdownMenuItem onClick={openFilePicker}>
Import persona file
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function LoadingSkeleton() {
return (
<div className={AGENT_CARD_GRID_CLASS}>
@@ -567,58 +316,3 @@ function LoadingSkeleton() {
</div>
);
}
function CollapsibleAgentGroup({
groupKey,
label,
agents,
collapsed,
startingAgentPubkey,
onToggle,
onOpenAgentProfile,
onStartAgent,
}: {
groupKey: string;
label: string;
agents: ManagedAgent[];
collapsed: ReadonlySet<string>;
startingAgentPubkey: string | null;
onToggle: (key: string) => void;
onOpenAgentProfile: (
pubkey: string,
options?: ProfilePanelOpenOptions,
) => void;
onStartAgent: (pubkey: string) => void;
}) {
const isCollapsed = collapsed.has(groupKey);
return (
<div className={`${AGENT_CARD_COLUMN_CLASS} space-y-2`}>
<button
className="group flex items-center gap-2 rounded-md px-1 py-1 text-left transition-colors hover:bg-muted/50"
onClick={() => onToggle(groupKey)}
type="button"
>
{isCollapsed ? (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
) : (
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="text-sm font-medium">{label}</span>
<span className="text-xs text-muted-foreground">({agents.length})</span>
</button>
{!isCollapsed ? (
<div className={AGENT_CARD_GRID_CLASS}>
{agents.map((agent) => (
<StandaloneAgentCard
agent={agent}
key={agent.pubkey}
startingAgentPubkey={startingAgentPubkey}
onOpenAgentProfile={onOpenAgentProfile}
onStartAgent={onStartAgent}
/>
))}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,117 @@
import type { ParsedPersonaPreview } from "@/shared/api/tauriPersonas";
import type { AgentTemplate, ManagedAgent } from "@/shared/api/types";
/**
* A prefilled starting point for the Create Agent dialog. Drafts come from
* the wizard's start step (blank / template / import) or from duplicating an
* existing agent. Submitting the dialog creates a managed agent directly —
* no persona record is involved.
*/
export type AgentDraft = {
title: string;
description: string;
submitLabel: string;
name: string;
avatarUrl: string;
systemPrompt: string;
/** Preferred ACP runtime id (e.g. "goose"). Hint only — the dialog falls back when unavailable. */
runtime: string | null;
model: string | null;
provider: string | null;
envVars: Record<string, string>;
};
type ImportedAvatarPreview = Pick<
ParsedPersonaPreview,
"avatarDataUrl" | "avatarRef"
>;
function isSafeImportedAvatarRef(
ref: string | null | undefined,
): ref is string {
const trimmed = ref?.trim();
if (!trimmed) return false;
try {
const parsed = new URL(trimmed);
return (
parsed.protocol === "http:" ||
parsed.protocol === "https:" ||
(parsed.protocol === "data:" && trimmed.startsWith("data:image/"))
);
} catch {
return false;
}
}
export function importedAvatarUrl(preview: ImportedAvatarPreview) {
if (preview.avatarDataUrl) return preview.avatarDataUrl;
return isSafeImportedAvatarRef(preview.avatarRef) ? preview.avatarRef : "";
}
export function blankAgentDraft(): AgentDraft {
return {
title: "Create agent",
description: "Set up a new agent and start it in this workspace.",
submitLabel: "Create agent",
name: "",
avatarUrl: "",
systemPrompt: "",
runtime: null,
model: null,
provider: null,
envVars: {},
};
}
export function templateAgentDraft(template: AgentTemplate): AgentDraft {
return {
title: `New ${template.displayName}`,
description: "Review this starter agent and adjust it as needed.",
submitLabel: "Create agent",
name: template.displayName,
avatarUrl: template.avatarUrl ?? "",
systemPrompt: template.systemPrompt,
runtime: template.runtime,
model: template.model,
provider: null,
envVars: {},
};
}
export function importAgentDraft(preview: ParsedPersonaPreview): AgentDraft {
return {
title: `Import ${preview.displayName}`,
description: "Review and create this imported agent.",
submitLabel: "Create agent",
name: preview.displayName,
avatarUrl: importedAvatarUrl(preview),
systemPrompt: preview.systemPrompt,
runtime: preview.runtime,
model: preview.model,
provider: preview.provider,
envVars: {},
};
}
export function duplicateAgentDraft(agent: ManagedAgent): AgentDraft {
return {
title: `Duplicate ${agent.name}`,
description:
"Create a new agent by copying this one and adjusting it as needed.",
submitLabel: "Create agent",
name: `${agent.name} copy`,
avatarUrl: agent.avatarUrl ?? "",
systemPrompt: agent.systemPrompt ?? "",
// agentCommand is the resolved/effective harness command; the dialog
// matches it against the runtime catalog by command.
runtime: agent.agentCommand || null,
model: agent.model,
provider: agent.provider,
// Carry env vars into the duplicate. Without this, a duplicated agent
// that relies on an API key in envVars would silently fail at spawn
// until the user re-entered every credential. The values are visible in
// the dialog and can be cleared for a blank slate.
envVars: { ...agent.envVars },
};
}

View File

@@ -1,9 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildBatchImportPersonaInput } from "./batchImportPersonaInput.ts";
import { buildBatchImportAgentInput } from "./batchImportPersonaInput.ts";
function persona(overrides = {}) {
function preview(overrides = {}) {
return {
displayName: "Imported Agent",
avatarDataUrl: null,
@@ -18,29 +18,29 @@ function persona(overrides = {}) {
};
}
test("buildBatchImportPersonaInput preserves provider from parsed personas", () => {
assert.deepEqual(buildBatchImportPersonaInput(persona()), {
displayName: "Imported Agent",
test("buildBatchImportAgentInput preserves provider from parsed files", () => {
assert.deepEqual(buildBatchImportAgentInput(preview()), {
name: "Imported Agent",
avatarUrl: undefined,
systemPrompt: "Use the imported provider.",
runtime: "goose",
model: "claude-sonnet-4",
provider: "anthropic",
namePool: undefined,
});
});
test("buildBatchImportPersonaInput carries imported name pools", () => {
test("buildBatchImportAgentInput uses the embedded avatar data url", () => {
assert.deepEqual(
buildBatchImportPersonaInput(persona({ namePool: ["fizz", "buzz"] })),
buildBatchImportAgentInput(
preview({ avatarDataUrl: "data:image/png;base64,abc" }),
),
{
displayName: "Imported Agent",
avatarUrl: undefined,
name: "Imported Agent",
avatarUrl: "data:image/png;base64,abc",
systemPrompt: "Use the imported provider.",
runtime: "goose",
model: "claude-sonnet-4",
provider: "anthropic",
namePool: ["fizz", "buzz"],
},
);
});

View File

@@ -1,17 +1,26 @@
import type { ParsedPersonaPreview } from "@/shared/api/tauriPersonas";
import type { CreatePersonaInput } from "@/shared/api/types";
import { importedAvatarUrl } from "./personaDialogState";
import { importedAvatarUrl } from "./agentDraft";
export function buildBatchImportPersonaInput(
persona: ParsedPersonaPreview,
): CreatePersonaInput {
/** Base fields for a managed agent created from an imported agent file. */
export type BatchImportAgentInput = {
name: string;
avatarUrl: string | undefined;
systemPrompt: string;
/** Preferred ACP runtime id hint from the file (resolved by the caller). */
runtime: string | undefined;
model: string | undefined;
provider: string | undefined;
};
export function buildBatchImportAgentInput(
preview: ParsedPersonaPreview,
): BatchImportAgentInput {
return {
displayName: persona.displayName,
avatarUrl: importedAvatarUrl(persona) || undefined,
systemPrompt: persona.systemPrompt,
runtime: persona.runtime ?? undefined,
model: persona.model ?? undefined,
provider: persona.provider ?? undefined,
namePool: persona.namePool.length > 0 ? persona.namePool : undefined,
name: preview.displayName,
avatarUrl: importedAvatarUrl(preview) || undefined,
systemPrompt: preview.systemPrompt,
runtime: preview.runtime ?? undefined,
model: preview.model ?? undefined,
provider: preview.provider ?? undefined,
};
}

View File

@@ -1,25 +0,0 @@
import type { EnvVarsValue } from "./EnvVarsEditor";
export function hasText(value: string | null | undefined): boolean {
return (value?.trim().length ?? 0) > 0;
}
export function hasAdvancedEnvVars(
value: EnvVarsValue,
managedKey: string | null,
): boolean {
return Object.keys(value).some((key) => key !== managedKey);
}
export function getAdvancedEnvVars(
value: EnvVarsValue,
managedKey: string | null,
): EnvVarsValue {
if (!managedKey || !(managedKey in value)) {
return value;
}
const next = { ...value };
delete next[managedKey];
return next;
}

View File

@@ -1,91 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getImportButtonLabel,
getImportButtonTone,
getImportErrorLabel,
IMPORT_ERROR_VISIBILITY_MS,
} from "./personaDialogImportState.ts";
test("getImportErrorLabel falls back to Invalid format for empty values", () => {
assert.equal(getImportErrorLabel(null), "Invalid format");
assert.equal(getImportErrorLabel(""), "Invalid format");
assert.equal(getImportErrorLabel(" "), "Invalid format");
});
test("getImportErrorLabel keeps meaningful parse errors", () => {
assert.equal(
getImportErrorLabel("Failed to parse persona file."),
"Failed to parse persona file.",
);
});
test("import button label prioritizes drag-drop affordance", () => {
assert.equal(
getImportButtonLabel({
isWindowFileDragOver: true,
isImportDragOver: false,
importErrorMessage: "Invalid format",
}),
"Drop .persona.json to import",
);
assert.equal(
getImportButtonLabel({
isWindowFileDragOver: false,
isImportDragOver: true,
importErrorMessage: "Invalid format",
}),
"Drop .persona.json to import",
);
});
test("import button label shows error when not dragging", () => {
assert.equal(
getImportButtonLabel({
isWindowFileDragOver: false,
isImportDragOver: false,
importErrorMessage: "Invalid format",
}),
"Invalid format",
);
assert.equal(
getImportButtonLabel({
isWindowFileDragOver: false,
isImportDragOver: false,
importErrorMessage: null,
}),
"Import",
);
});
test("import button tone follows drag > error > default priority", () => {
assert.equal(
getImportButtonTone({
isWindowFileDragOver: true,
isImportDragOver: false,
importErrorMessage: "Invalid format",
}),
"drag",
);
assert.equal(
getImportButtonTone({
isWindowFileDragOver: false,
isImportDragOver: false,
importErrorMessage: "Invalid format",
}),
"error",
);
assert.equal(
getImportButtonTone({
isWindowFileDragOver: false,
isImportDragOver: false,
importErrorMessage: null,
}),
"default",
);
});
test("import error visibility duration is 5 seconds", () => {
assert.equal(IMPORT_ERROR_VISIBILITY_MS, 5_000);
});

View File

@@ -1,40 +0,0 @@
type ImportButtonStateInput = {
isWindowFileDragOver: boolean;
isImportDragOver: boolean;
importErrorMessage: string | null;
};
export const IMPORT_ERROR_VISIBILITY_MS = 5_000;
export function getImportErrorLabel(errorMessage: string | null): string {
const message = (errorMessage ?? "").trim();
return message.length > 0 ? message : "Invalid format";
}
export function getImportButtonLabel({
isWindowFileDragOver,
isImportDragOver,
importErrorMessage,
}: ImportButtonStateInput): string {
if (isWindowFileDragOver || isImportDragOver) {
return "Drop .persona.json to import";
}
if (importErrorMessage !== null) {
return getImportErrorLabel(importErrorMessage);
}
return "Import";
}
export function getImportButtonTone({
isWindowFileDragOver,
isImportDragOver,
importErrorMessage,
}: ImportButtonStateInput): "drag" | "error" | "default" {
if (isWindowFileDragOver || isImportDragOver) {
return "drag";
}
if (importErrorMessage !== null) {
return "error";
}
return "default";
}

View File

@@ -1,366 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
canSubmitPersonaDialog,
createPersonaDialogState,
duplicatePersonaDialogState,
editPersonaDialogState,
formatPersonaNamePoolText,
importPersonaDialogState,
parsePersonaNamePoolText,
} from "./personaDialogState.ts";
test("canSubmitPersonaDialog requires a display name but not a system prompt", () => {
// Empty system prompt is allowed: core memory is auto-injected, so the
// persona prompt is optional. Only the display name gates submission.
assert.equal(
canSubmitPersonaDialog({ displayName: "Coder", isPending: false }),
true,
);
assert.equal(
canSubmitPersonaDialog({ displayName: " Coder ", isPending: false }),
true,
);
});
test("canSubmitPersonaDialog blocks an empty or whitespace display name", () => {
assert.equal(
canSubmitPersonaDialog({ displayName: "", isPending: false }),
false,
);
assert.equal(
canSubmitPersonaDialog({ displayName: " ", isPending: false }),
false,
);
});
test("canSubmitPersonaDialog blocks while a save is pending", () => {
assert.equal(
canSubmitPersonaDialog({ displayName: "Coder", isPending: true }),
false,
);
});
test("persona name pool helpers parse, format, and clear values", () => {
assert.deepEqual(parsePersonaNamePoolText("Birch, Compass, , Ridge "), [
"Birch",
"Compass",
"Ridge",
]);
assert.deepEqual(parsePersonaNamePoolText(" "), []);
assert.equal(
formatPersonaNamePoolText(["Birch", "Compass", "Ridge"]),
"Birch, Compass, Ridge",
);
assert.equal(formatPersonaNamePoolText(undefined), "");
});
test("createPersonaDialogState returns a fresh empty draft", () => {
const first = createPersonaDialogState();
const second = createPersonaDialogState();
assert.equal(first.title, "Create agent");
assert.deepEqual(first.initialValues, {
displayName: "",
avatarUrl: "",
systemPrompt: "",
runtime: undefined,
model: undefined,
});
assert.notStrictEqual(first.initialValues, second.initialValues);
});
test("duplicatePersonaDialogState copies persona fields into a new draft", () => {
const state = duplicatePersonaDialogState({
id: "persona-1",
displayName: "Solo",
avatarUrl: "avatar://solo",
systemPrompt: "Be direct.",
runtime: "provider-a",
model: "model-a",
provider: null,
isBuiltIn: false,
isActive: true,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
});
assert.deepEqual(state.initialValues, {
displayName: "Solo copy",
avatarUrl: "avatar://solo",
systemPrompt: "Be direct.",
runtime: "provider-a",
model: "model-a",
provider: undefined,
namePool: [],
envVars: {},
});
});
test("duplicatePersonaDialogState carries envVars and namePool into the duplicate", () => {
// Regression: codex R10 P2. Without this, a duplicated persona that
// relies on an API key in env_vars would silently fail at spawn until
// the user re-entered every credential.
const state = duplicatePersonaDialogState({
id: "persona-with-secrets",
displayName: "Coder",
avatarUrl: null,
systemPrompt: "Write code.",
runtime: null,
model: null,
isBuiltIn: false,
isActive: true,
namePool: ["alice", "bob"],
envVars: { ANTHROPIC_API_KEY: "sk-test", GOOSE_PROVIDER: "anthropic" },
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
});
assert.deepEqual(state.initialValues.envVars, {
ANTHROPIC_API_KEY: "sk-test",
GOOSE_PROVIDER: "anthropic",
});
assert.deepEqual(state.initialValues.namePool, ["alice", "bob"]);
});
test("editPersonaDialogState preserves the persona id for updates", () => {
const state = editPersonaDialogState({
id: "persona-2",
displayName: "Kit",
avatarUrl: null,
systemPrompt: "Keep it weird.",
runtime: null,
model: null,
provider: null,
isBuiltIn: true,
isActive: true,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
});
assert.equal(state.title, "Edit agent");
assert.equal(state.description, "");
assert.equal(state.submitLabel, "Save changes");
assert.deepEqual(state.initialValues, {
id: "persona-2",
displayName: "Kit",
avatarUrl: "",
systemPrompt: "Keep it weird.",
runtime: undefined,
model: undefined,
provider: undefined,
namePool: [],
envVars: {},
});
});
test("editPersonaDialogState seeds envVars and namePool from the persona", () => {
const state = editPersonaDialogState({
id: "persona-3",
displayName: "Coder",
avatarUrl: null,
systemPrompt: "Write code.",
runtime: null,
model: null,
isBuiltIn: false,
isActive: true,
namePool: ["alice", "bob"],
envVars: { ANTHROPIC_API_KEY: "sk-test" },
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
});
assert.deepEqual(state.initialValues.envVars, {
ANTHROPIC_API_KEY: "sk-test",
});
assert.deepEqual(state.initialValues.namePool, ["alice", "bob"]);
});
test("importPersonaDialogState maps parsed persona previews into create drafts", () => {
const state = importPersonaDialogState({
displayName: "Imported",
avatarDataUrl: null,
avatarRef: null,
systemPrompt: "Imported prompt",
runtime: null,
model: "model-b",
provider: null,
namePool: [],
sourceFile: "import.persona.json",
});
assert.equal(state.title, "Import Imported");
assert.deepEqual(state.initialValues, {
displayName: "Imported",
avatarUrl: "",
systemPrompt: "Imported prompt",
runtime: undefined,
model: "model-b",
provider: undefined,
});
});
test("importPersonaDialogState preserves imported name pools", () => {
const state = importPersonaDialogState({
displayName: "Named imports",
avatarDataUrl: null,
avatarRef: null,
systemPrompt: "Imported prompt",
runtime: null,
model: null,
provider: null,
namePool: ["Birch", "Compass"],
sourceFile: "named.persona.json",
});
assert.deepEqual(state.initialValues.namePool, ["Birch", "Compass"]);
});
test("importPersonaDialogState filters unresolved Goose app-avatar refs", () => {
const state = importPersonaDialogState({
displayName: "Goosey",
avatarDataUrl: null,
avatarRef: "app-avatar:gloopies-19",
systemPrompt: "Imported prompt",
runtime: null,
model: null,
provider: null,
namePool: [],
sourceFile: "goosey.persona.md",
});
assert.equal(state.initialValues.avatarUrl, "");
});
test("importPersonaDialogState filters nonpersistent imported avatar refs", () => {
for (const avatarRef of [
"blob:https://buzz.example/temporary-avatar",
"ipfs://bafybeigdyrzt",
]) {
const state = importPersonaDialogState({
displayName: "Packed avatar",
avatarDataUrl: null,
avatarRef,
systemPrompt: "Imported prompt",
runtime: null,
model: null,
provider: null,
namePool: [],
sourceFile: "pack.persona.zip",
});
assert.equal(state.initialValues.avatarUrl, "");
}
});
test("importPersonaDialogState preserves URL-like avatar refs", () => {
const state = importPersonaDialogState({
displayName: "Hosted avatar",
avatarDataUrl: null,
avatarRef: "https://relay.example/avatar.png",
systemPrompt: "Imported prompt",
runtime: null,
model: null,
provider: null,
namePool: [],
sourceFile: "hosted.persona.md",
});
assert.equal(
state.initialValues.avatarUrl,
"https://relay.example/avatar.png",
);
});
test("importPersonaDialogState filters relative avatar refs from packs", () => {
const state = importPersonaDialogState({
displayName: "Packed avatar",
avatarDataUrl: null,
avatarRef: "./avatars/lep.png",
systemPrompt: "Imported prompt",
runtime: null,
model: null,
provider: null,
namePool: [],
sourceFile: "pack.persona.zip",
});
assert.equal(state.initialValues.avatarUrl, "");
});
test("editPersonaDialogState preserves provider=databricks", () => {
const state = editPersonaDialogState({
id: "persona-provider",
displayName: "DB Agent",
avatarUrl: null,
systemPrompt: "Use databricks.",
runtime: "goose",
model: "dbrx",
provider: "databricks",
isBuiltIn: false,
isActive: true,
namePool: [],
envVars: {},
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
});
assert.equal(state.initialValues.provider, "databricks");
});
test("editPersonaDialogState maps provider=null to undefined", () => {
const state = editPersonaDialogState({
id: "persona-no-provider",
displayName: "Plain",
avatarUrl: null,
systemPrompt: "No provider.",
runtime: null,
model: null,
provider: null,
isBuiltIn: false,
isActive: true,
namePool: [],
envVars: {},
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
});
assert.equal(state.initialValues.provider, undefined);
});
test("duplicatePersonaDialogState preserves provider=databricks", () => {
const state = duplicatePersonaDialogState({
id: "persona-dup-provider",
displayName: "DB Agent",
avatarUrl: null,
systemPrompt: "Use databricks.",
runtime: "goose",
model: "dbrx",
provider: "databricks",
isBuiltIn: false,
isActive: true,
namePool: [],
envVars: {},
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
});
assert.equal(state.initialValues.provider, "databricks");
});
test("importPersonaDialogState preserves provider=anthropic", () => {
const state = importPersonaDialogState({
displayName: "Imported With Provider",
avatarDataUrl: null,
avatarRef: null,
systemPrompt: "Anthropic agent.",
runtime: "goose",
model: "claude-sonnet",
provider: "anthropic",
namePool: [],
sourceFile: "provider-test.persona.json",
});
assert.equal(state.initialValues.provider, "anthropic");
});

View File

@@ -1,152 +0,0 @@
import type { ParsedPersonaPreview } from "@/shared/api/tauriPersonas";
import type {
AgentPersona,
CreatePersonaInput,
UpdatePersonaInput,
} from "@/shared/api/types";
export type PersonaDialogState = {
description: string;
initialValues: CreatePersonaInput | UpdatePersonaInput;
submitLabel: string;
title: string;
};
type ImportedPersonaAvatarPreview = Pick<
ParsedPersonaPreview,
"avatarDataUrl" | "avatarRef"
>;
/**
* Whether the persona dialog's save action should be enabled.
*
* A display name is the only required field. The system prompt is optional:
* core memory is auto-injected at runtime, so a persona need not carry its
* own prompt. `isPending` blocks double-submits while a save is in flight.
*/
export function canSubmitPersonaDialog(args: {
displayName: string;
isPending: boolean;
}): boolean {
return args.displayName.trim().length > 0 && !args.isPending;
}
function isSafeImportedAvatarRef(
ref: string | null | undefined,
): ref is string {
const trimmed = ref?.trim();
if (!trimmed) return false;
try {
const parsed = new URL(trimmed);
return (
parsed.protocol === "http:" ||
parsed.protocol === "https:" ||
(parsed.protocol === "data:" && trimmed.startsWith("data:image/"))
);
} catch {
return false;
}
}
export function importedAvatarUrl(persona: ImportedPersonaAvatarPreview) {
if (persona.avatarDataUrl) return persona.avatarDataUrl;
return isSafeImportedAvatarRef(persona.avatarRef) ? persona.avatarRef : "";
}
export function formatPersonaNamePoolText(namePool: string[] | undefined) {
return namePool?.join(", ") ?? "";
}
export function parsePersonaNamePoolText(text: string): string[] {
return text
.split(",")
.map((value) => value.trim())
.filter((value) => value.length > 0);
}
export function createPersonaDialogState(): PersonaDialogState {
return {
title: "Create agent",
description:
"Create an agent profile and start its managed agent instance.",
submitLabel: "Create agent",
initialValues: {
displayName: "",
avatarUrl: "",
systemPrompt: "",
runtime: undefined,
model: undefined,
},
};
}
export function duplicatePersonaDialogState(
persona: AgentPersona,
): PersonaDialogState {
return {
title: `Duplicate ${persona.displayName}`,
description:
"Create a new agent by copying this profile and adjusting it as needed.",
submitLabel: "Create agent",
initialValues: {
displayName: `${persona.displayName} copy`,
avatarUrl: persona.avatarUrl ?? "",
systemPrompt: persona.systemPrompt,
runtime: persona.runtime ?? undefined,
model: persona.model ?? undefined,
provider: persona.provider ?? undefined,
// Carry envVars and namePool into the duplicate. Without this, a
// duplicated persona that relies on an API key in env_vars would
// silently fail at spawn until the user re-entered every credential.
// The user sees the inherited values in the dialog and can clear
// them if they want a blank template.
namePool: persona.namePool ?? [],
envVars: persona.envVars ?? {},
},
};
}
export function editPersonaDialogState(
persona: AgentPersona,
): PersonaDialogState {
return {
title: "Edit agent",
description: "",
submitLabel: "Save changes",
initialValues: {
id: persona.id,
displayName: persona.displayName,
avatarUrl: persona.avatarUrl ?? "",
systemPrompt: persona.systemPrompt,
runtime: persona.runtime ?? undefined,
model: persona.model ?? undefined,
provider: persona.provider ?? undefined,
// Seed both namePool and envVars from the loaded persona so editing
// unrelated fields doesn't submit an empty value that wipes them.
// (Persona update treats Some(empty) as "clear all" intentionally;
// the dialog must therefore round-trip the existing values.)
namePool: persona.namePool ?? [],
envVars: persona.envVars ?? {},
},
};
}
export function importPersonaDialogState(
persona: ParsedPersonaPreview,
): PersonaDialogState {
return {
title: `Import ${persona.displayName}`,
description: "Review and create this imported agent.",
submitLabel: "Create agent",
initialValues: {
displayName: persona.displayName,
avatarUrl: importedAvatarUrl(persona),
systemPrompt: persona.systemPrompt,
runtime: persona.runtime ?? undefined,
model: persona.model ?? undefined,
provider: persona.provider ?? undefined,
...(persona.namePool.length > 0 ? { namePool: persona.namePool } : {}),
},
};
}

View File

@@ -1,235 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildPersonaImportPlan,
hasAnyPersonaImportChanges,
} from "./personaImportPlan.ts";
function createPersona(overrides = {}) {
return {
id: "persona-1",
displayName: "Alice",
avatarUrl: null,
systemPrompt: "Be helpful.",
runtime: null,
model: null,
provider: null,
namePool: [],
isBuiltIn: false,
isActive: true,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
};
}
function createPreview(overrides = {}) {
return {
displayName: "Alice",
systemPrompt: "Be helpful.",
avatarDataUrl: null,
avatarRef: null,
runtime: null,
model: null,
provider: null,
namePool: [],
sourceFile: "alice.persona.json",
...overrides,
};
}
test("buildPersonaImportPlan returns empty fields when nothing changed", () => {
const plan = buildPersonaImportPlan({
persona: createPersona(),
preview: createPreview(),
});
assert.equal(plan.fields.length, 0);
});
test("buildPersonaImportPlan detects display name change", () => {
const plan = buildPersonaImportPlan({
persona: createPersona({ displayName: "Alice" }),
preview: createPreview({ displayName: "Alicia" }),
});
assert.equal(plan.fields.length, 1);
assert.equal(plan.fields[0]?.field, "displayName");
assert.equal(plan.fields[0]?.label, "Display name");
assert.equal(plan.fields[0]?.existingValue, "Alice");
assert.equal(plan.fields[0]?.importedValue, "Alicia");
assert.equal(plan.fields[0]?.addedLines, 1);
assert.equal(plan.fields[0]?.removedLines, 1);
});
test("buildPersonaImportPlan detects system prompt change with line counts", () => {
const plan = buildPersonaImportPlan({
persona: createPersona({ systemPrompt: "Line one\nLine two" }),
preview: createPreview({
systemPrompt: "Line one\nLine two\nLine three",
}),
});
assert.equal(plan.fields.length, 1);
assert.equal(plan.fields[0]?.field, "systemPrompt");
assert.equal(plan.fields[0]?.addedLines, 1);
assert.equal(plan.fields[0]?.removedLines, 0);
});
test("buildPersonaImportPlan detects avatar change", () => {
const plan = buildPersonaImportPlan({
persona: createPersona({ avatarUrl: "https://old.png" }),
preview: createPreview({ avatarDataUrl: "https://new.png" }),
});
assert.equal(plan.fields.length, 1);
assert.equal(plan.fields[0]?.field, "avatarUrl");
});
test("buildPersonaImportPlan detects hosted avatar ref changes", () => {
const plan = buildPersonaImportPlan({
persona: createPersona({ avatarUrl: null }),
preview: createPreview({ avatarRef: "https://new.example/avatar.png" }),
});
assert.equal(plan.fields.length, 1);
assert.equal(plan.fields[0]?.field, "avatarUrl");
assert.equal(plan.fields[0]?.importedValue, "https://new.example/avatar.png");
});
test("buildPersonaImportPlan ignores unresolved app-avatar refs", () => {
const plan = buildPersonaImportPlan({
persona: createPersona({ avatarUrl: "https://old.example/avatar.png" }),
preview: createPreview({ avatarRef: "app-avatar:gloopies-19" }),
});
assert.equal(
plan.fields.some((field) => field.field === "avatarUrl"),
false,
);
});
test("buildPersonaImportPlan ignores raw relative avatar refs", () => {
const plan = buildPersonaImportPlan({
persona: createPersona({ avatarUrl: "https://old.example/avatar.png" }),
preview: createPreview({ avatarRef: "./avatars/lep.png" }),
});
assert.equal(
plan.fields.some((field) => field.field === "avatarUrl"),
false,
);
});
test("buildPersonaImportPlan ignores nonpersistent imported avatar refs", () => {
for (const avatarRef of [
"blob:https://buzz.example/temporary-avatar",
"ipfs://bafybeigdyrzt",
]) {
const plan = buildPersonaImportPlan({
persona: createPersona({ avatarUrl: "https://old.example/avatar.png" }),
preview: createPreview({ avatarRef }),
});
assert.equal(
plan.fields.some((field) => field.field === "avatarUrl"),
false,
);
}
});
test("buildPersonaImportPlan detects runtime change", () => {
const plan = buildPersonaImportPlan({
persona: createPersona({ runtime: "goose" }),
preview: createPreview({ runtime: "claude" }),
});
assert.equal(plan.fields.length, 1);
assert.equal(plan.fields[0]?.field, "runtime");
assert.equal(plan.fields[0]?.label, "Preferred runtime");
});
test("buildPersonaImportPlan detects model change", () => {
const plan = buildPersonaImportPlan({
persona: createPersona({ model: "gpt-4o" }),
preview: createPreview({ model: "claude-sonnet-4-20250514" }),
});
assert.equal(plan.fields.length, 1);
assert.equal(plan.fields[0]?.field, "model");
assert.equal(plan.fields[0]?.label, "Preferred model");
});
test("buildPersonaImportPlan detects provider change", () => {
const plan = buildPersonaImportPlan({
persona: createPersona({ provider: "anthropic" }),
preview: createPreview({ provider: "databricks" }),
});
assert.equal(plan.fields.length, 1);
assert.equal(plan.fields[0]?.field, "provider");
assert.equal(plan.fields[0]?.label, "LLM provider");
assert.equal(plan.fields[0]?.existingValue, "anthropic");
assert.equal(plan.fields[0]?.importedValue, "databricks");
});
test("buildPersonaImportPlan detects name pool change", () => {
const plan = buildPersonaImportPlan({
persona: createPersona({ namePool: ["Birch", "Compass"] }),
preview: createPreview({ namePool: ["Ridge", "Thistle"] }),
});
assert.equal(plan.fields.length, 1);
assert.equal(plan.fields[0]?.field, "namePool");
assert.equal(plan.fields[0]?.label, "Instance name pool");
});
test("buildPersonaImportPlan detects multiple field changes", () => {
const plan = buildPersonaImportPlan({
persona: createPersona({
displayName: "Alice",
systemPrompt: "Old prompt",
model: "gpt-4o",
provider: "openai",
}),
preview: createPreview({
displayName: "Alicia",
systemPrompt: "New prompt",
model: "claude-sonnet-4-20250514",
provider: "anthropic",
}),
});
assert.equal(plan.fields.length, 4);
const fieldNames = plan.fields.map((f) => f.field);
assert.deepEqual(fieldNames, [
"displayName",
"systemPrompt",
"model",
"provider",
]);
});
test("hasAnyPersonaImportChanges returns false for empty plan", () => {
assert.equal(hasAnyPersonaImportChanges({ fields: [] }), false);
assert.equal(hasAnyPersonaImportChanges(null), false);
});
test("hasAnyPersonaImportChanges returns true when fields have changes", () => {
assert.equal(
hasAnyPersonaImportChanges({
fields: [
{
field: "displayName",
label: "Display name",
existingValue: "A",
importedValue: "B",
addedLines: 1,
removedLines: 1,
},
],
}),
true,
);
});

View File

@@ -1,213 +0,0 @@
import type { ParsedPersonaPreview } from "@/shared/api/tauriPersonas";
import type { AgentPersona } from "@/shared/api/types";
import { importedAvatarUrl } from "./personaDialogState";
type LineChangeCounts = {
addedLines: number;
removedLines: number;
};
export type PersonaImportFieldChange = {
field: string;
label: string;
existingValue: string;
importedValue: string;
addedLines: number;
removedLines: number;
};
export type PersonaImportPlan = {
fields: PersonaImportFieldChange[];
};
type BuildPersonaImportPlanInput = {
persona: AgentPersona;
preview: ParsedPersonaPreview;
};
function normalizeOptionalText(value: string | null | undefined): string {
return (value ?? "").trim();
}
function normalizePromptLines(prompt: string): string[] {
const normalized = prompt.replace(/\r\n/g, "\n");
const lines = normalized.split("\n").map((line) => line.trimEnd());
while (lines.length > 0 && lines[lines.length - 1] === "") {
lines.pop();
}
return lines;
}
function countLineChanges(
previousLines: string[],
nextLines: string[],
): LineChangeCounts {
const previousLength = previousLines.length;
const nextLength = nextLines.length;
if (previousLength === 0) {
return { addedLines: nextLength, removedLines: 0 };
}
if (nextLength === 0) {
return { addedLines: 0, removedLines: previousLength };
}
const lcs = Array.from({ length: previousLength + 1 }, () =>
Array<number>(nextLength + 1).fill(0),
);
for (let i = previousLength - 1; i >= 0; i -= 1) {
for (let j = nextLength - 1; j >= 0; j -= 1) {
if (previousLines[i] === nextLines[j]) {
lcs[i][j] = lcs[i + 1][j + 1] + 1;
} else {
lcs[i][j] = Math.max(lcs[i + 1][j], lcs[i][j + 1]);
}
}
}
let i = 0;
let j = 0;
let addedLines = 0;
let removedLines = 0;
while (i < previousLength && j < nextLength) {
if (previousLines[i] === nextLines[j]) {
i += 1;
j += 1;
continue;
}
if (lcs[i + 1][j] >= lcs[i][j + 1]) {
removedLines += 1;
i += 1;
} else {
addedLines += 1;
j += 1;
}
}
removedLines += previousLength - i;
addedLines += nextLength - j;
return { addedLines, removedLines };
}
function singleLineChanges(
existing: string,
imported: string,
): LineChangeCounts {
return countLineChanges(
normalizePromptLines(existing),
normalizePromptLines(imported),
);
}
function namePoolToString(pool: string[] | undefined): string {
return (pool ?? []).join(", ");
}
export function buildPersonaImportPlan({
persona,
preview,
}: BuildPersonaImportPlanInput): PersonaImportPlan {
const fields: PersonaImportFieldChange[] = [];
const existingDisplayName = normalizeOptionalText(persona.displayName);
const importedDisplayName = normalizeOptionalText(preview.displayName);
if (existingDisplayName !== importedDisplayName) {
fields.push({
field: "displayName",
label: "Display name",
existingValue: existingDisplayName,
importedValue: importedDisplayName,
...singleLineChanges(existingDisplayName, importedDisplayName),
});
}
const existingAvatar = normalizeOptionalText(persona.avatarUrl);
const importedAvatar = normalizeOptionalText(importedAvatarUrl(preview));
const hasUnusableAvatarRef =
!preview.avatarDataUrl &&
Boolean(preview.avatarRef?.trim()) &&
!importedAvatar;
if (!hasUnusableAvatarRef && existingAvatar !== importedAvatar) {
fields.push({
field: "avatarUrl",
label: "Avatar",
existingValue: existingAvatar,
importedValue: importedAvatar,
...singleLineChanges(existingAvatar, importedAvatar),
});
}
const existingPrompt = normalizeOptionalText(persona.systemPrompt);
const importedPrompt = normalizeOptionalText(preview.systemPrompt);
if (existingPrompt !== importedPrompt) {
fields.push({
field: "systemPrompt",
label: "System prompt",
existingValue: existingPrompt,
importedValue: importedPrompt,
...countLineChanges(
normalizePromptLines(existingPrompt),
normalizePromptLines(importedPrompt),
),
});
}
const existingRuntime = normalizeOptionalText(persona.runtime);
const importedRuntime = normalizeOptionalText(preview.runtime);
if (existingRuntime !== importedRuntime) {
fields.push({
field: "runtime",
label: "Preferred runtime",
existingValue: existingRuntime,
importedValue: importedRuntime,
...singleLineChanges(existingRuntime, importedRuntime),
});
}
const existingModel = normalizeOptionalText(persona.model);
const importedModel = normalizeOptionalText(preview.model);
if (existingModel !== importedModel) {
fields.push({
field: "model",
label: "Preferred model",
existingValue: existingModel,
importedValue: importedModel,
...singleLineChanges(existingModel, importedModel),
});
}
const existingProvider = normalizeOptionalText(persona.provider);
const importedProvider = normalizeOptionalText(preview.provider);
if (existingProvider !== importedProvider) {
fields.push({
field: "provider",
label: "LLM provider",
existingValue: existingProvider,
importedValue: importedProvider,
...singleLineChanges(existingProvider, importedProvider),
});
}
const existingNamePool = namePoolToString(persona.namePool);
const importedNamePool = namePoolToString(preview.namePool);
if (existingNamePool !== importedNamePool) {
fields.push({
field: "namePool",
label: "Instance name pool",
existingValue: existingNamePool,
importedValue: importedNamePool,
...singleLineChanges(existingNamePool, importedNamePool),
});
}
return { fields };
}
export function hasAnyPersonaImportChanges(
plan: PersonaImportPlan | null,
): boolean {
return (plan?.fields.length ?? 0) > 0;
}

View File

@@ -1,60 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildPersonaImportUpdateInput } from "./personaImportUpdateInput.ts";
function createPersona(overrides = {}) {
return {
id: "persona-1",
displayName: "Alice",
avatarUrl: null,
systemPrompt: "Be helpful.",
runtime: "goose",
model: "claude-sonnet-4",
provider: "anthropic",
namePool: [],
isBuiltIn: false,
isActive: true,
envVars: {},
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
};
}
function createPreview(overrides = {}) {
return {
displayName: "Alice",
systemPrompt: "Be helpful.",
avatarDataUrl: null,
avatarRef: null,
runtime: "goose",
model: "gpt-5",
provider: "databricks",
namePool: [],
sourceFile: "alice.md",
...overrides,
};
}
test("buildPersonaImportUpdateInput applies selected model and provider updates", () => {
const input = buildPersonaImportUpdateInput({
existing: createPersona(),
preview: createPreview(),
selectedFields: ["model", "provider"],
});
assert.equal(input.model, "gpt-5");
assert.equal(input.provider, "databricks");
});
test("buildPersonaImportUpdateInput preserves provider when provider is not selected", () => {
const input = buildPersonaImportUpdateInput({
existing: createPersona(),
preview: createPreview(),
selectedFields: ["model"],
});
assert.equal(input.model, "gpt-5");
assert.equal(input.provider, "anthropic");
});

View File

@@ -1,46 +0,0 @@
import type { ParsedPersonaPreview } from "@/shared/api/tauriPersonas";
import type { AgentPersona, UpdatePersonaInput } from "@/shared/api/types";
import { importedAvatarUrl } from "./personaDialogState";
type BuildPersonaImportUpdateInputArgs = {
existing: AgentPersona;
preview: ParsedPersonaPreview;
selectedFields: Iterable<string>;
};
export function buildPersonaImportUpdateInput({
existing,
preview,
selectedFields,
}: BuildPersonaImportUpdateInputArgs): UpdatePersonaInput {
const selectedFieldSet = new Set(selectedFields);
return {
id: existing.id,
displayName: selectedFieldSet.has("displayName")
? preview.displayName
: existing.displayName,
systemPrompt: selectedFieldSet.has("systemPrompt")
? preview.systemPrompt
: existing.systemPrompt,
avatarUrl: selectedFieldSet.has("avatarUrl")
? importedAvatarUrl(preview) || undefined
: (existing.avatarUrl ?? undefined),
runtime: selectedFieldSet.has("runtime")
? (preview.runtime ?? undefined)
: (existing.runtime ?? undefined),
model: selectedFieldSet.has("model")
? (preview.model ?? undefined)
: (existing.model ?? undefined),
provider: selectedFieldSet.has("provider")
? (preview.provider ?? undefined)
: (existing.provider ?? undefined),
namePool: selectedFieldSet.has("namePool")
? preview.namePool.length > 0
? preview.namePool
: undefined
: existing.namePool.length > 0
? [...existing.namePool]
: undefined,
};
}

View File

@@ -1,85 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getFieldPreview,
getFieldSecondaryText,
getSelectedFieldCount,
} from "./personaImportUpdateState.ts";
test("getFieldSecondaryText returns imported preview when selected", () => {
assert.equal(
getFieldSecondaryText(true, "New prompt text", "Old prompt text"),
"New prompt text",
);
});
test("getFieldSecondaryText returns current value when not selected", () => {
assert.equal(
getFieldSecondaryText(false, "New prompt text", "Old prompt text"),
"Old prompt text",
);
});
test("getFieldPreview returns fallback for empty values", () => {
assert.equal(getFieldPreview("", "No value."), "No value.");
assert.equal(getFieldPreview(" ", "No value."), "No value.");
assert.equal(getFieldPreview("\n\n", "No value."), "No value.");
});
test("getFieldPreview returns first non-empty line", () => {
assert.equal(getFieldPreview("Hello world", "fallback"), "Hello world");
assert.equal(
getFieldPreview("\n First line\nSecond line", "fallback"),
"First line",
);
});
test("getFieldPreview truncates long lines", () => {
const longLine = "a".repeat(300);
const result = getFieldPreview(longLine, "fallback", 240);
assert.equal(result.length, 241);
assert.ok(result.endsWith("…"));
});
test("getSelectedFieldCount counts selected fields", () => {
const fields = [
{
field: "displayName",
label: "Display name",
existingValue: "A",
importedValue: "B",
addedLines: 1,
removedLines: 1,
},
{
field: "systemPrompt",
label: "System prompt",
existingValue: "X",
importedValue: "Y",
addedLines: 1,
removedLines: 1,
},
{
field: "model",
label: "Preferred model",
existingValue: "m1",
importedValue: "m2",
addedLines: 1,
removedLines: 1,
},
];
assert.equal(
getSelectedFieldCount(fields, new Set(["displayName", "model"])),
2,
);
assert.equal(getSelectedFieldCount(fields, new Set()), 0);
assert.equal(
getSelectedFieldCount(
fields,
new Set(["displayName", "systemPrompt", "model"]),
),
3,
);
});

View File

@@ -1,36 +0,0 @@
import type { PersonaImportFieldChange } from "./personaImportPlan";
export function getFieldSecondaryText(
shouldUpdate: boolean,
importedPreview: string,
currentPreview: string,
): string {
return shouldUpdate ? importedPreview : currentPreview;
}
export function getFieldPreview(
value: string,
emptyFallback: string,
maxLength = 240,
): string {
const normalized = value.replace(/\r\n/g, "\n").trim();
if (normalized.length === 0) {
return emptyFallback;
}
const firstNonEmptyLine =
normalized
.split("\n")
.map((line) => line.trim())
.find((line) => line.length > 0) ?? normalized;
if (firstNonEmptyLine.length <= maxLength) {
return firstNonEmptyLine;
}
return `${firstNonEmptyLine.slice(0, maxLength).trimEnd()}…`;
}
export function getSelectedFieldCount(
fields: PersonaImportFieldChange[],
selectedFields: Set<string>,
): number {
return fields.filter((field) => selectedFields.has(field.field)).length;
}

View File

@@ -1,66 +0,0 @@
export const personaLibraryCopy = {
title: "My agents",
description:
"The personas you have chosen for this app. Use them to create teams and launch agents.",
chooseFromCatalog: "Choose from catalog",
createNew: "New agent",
customAgent: "Custom agent",
import: "Import",
emptyTitle: "No agents yet",
emptyDescription:
"Choose one from Agent Catalog, add your own persona, or import one to get started.",
emptyImportHint:
"Or drop a .persona.md, .persona.json, .persona.png, or .zip file here to import.",
} as const;
export const personaCatalogCopy = {
title: "Agent Catalog",
description: "Browse built-in agents and add them to My Agents.",
dialogTitle: "Agent Catalog",
dialogDescription: "Browse built-in agents and add them to My Agents.",
emptyTitle: "You're all set",
emptyDescription: "Everything in Agent Catalog is already in My Agents.",
emptyCatalogDescription:
"New agents will show up here when the app ships more options.",
emptyCatalogTitle: "No agents in the catalog yet",
detailsAction: "View details",
selectAction: "Choose",
deselectAction: "Selected",
selectedState: "Selected",
availableState: "Available",
detailSelectedTitle: "Selected for My Agents",
detailSelectedDescription:
"Turn this off to remove the persona from teams and agent creation in this app.",
detailAvailableTitle: "Available in Agent Catalog",
detailAvailableDescription:
"Turn this on to make the persona available for teams and agent creation.",
useAction: "Add agent",
addedAction: "Added to My Agents",
teamEmptyState:
"No personas in My Agents yet. Create one or choose one from Agent Catalog first.",
} as const;
export function getPersonaCatalogSelectionActionCopy(isActive: boolean) {
return isActive
? personaCatalogCopy.deselectAction
: personaCatalogCopy.selectAction;
}
export function getPersonaCatalogSelectionAriaLabel(
displayName: string,
isActive: boolean,
) {
return `${isActive ? "Deselect" : "Select"} ${displayName} in My Agents`;
}
export function getPersonaCatalogDetailSelectionCopy(isActive: boolean) {
return isActive
? {
title: personaCatalogCopy.detailSelectedTitle,
description: personaCatalogCopy.detailSelectedDescription,
}
: {
title: personaCatalogCopy.detailAvailableTitle,
description: personaCatalogCopy.detailAvailableDescription,
};
}

View File

@@ -1,92 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
copySelectedPersonaIds,
countMissingPersonaIds,
filterAvailablePersonaIds,
orderPersonasByInitiallySelected,
} from "./teamDialogSelection.ts";
function createPersona(id) {
return {
id,
displayName: id,
avatarUrl: null,
systemPrompt: "Prompt",
runtime: null,
model: null,
namePool: [],
isBuiltIn: false,
isActive: true,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
};
}
test("copySelectedPersonaIds keeps initial team members until submit filtering", () => {
const selectedPersonaIds = copySelectedPersonaIds([
"persona:available",
"persona:late",
]);
const initiallyAvailable = [createPersona("persona:available")];
const laterAvailable = [
createPersona("persona:available"),
createPersona("persona:late"),
];
assert.deepEqual(selectedPersonaIds, ["persona:available", "persona:late"]);
assert.deepEqual(
filterAvailablePersonaIds(selectedPersonaIds, initiallyAvailable),
["persona:available"],
);
assert.deepEqual(
filterAvailablePersonaIds(selectedPersonaIds, laterAvailable),
["persona:available", "persona:late"],
);
});
test("countMissingPersonaIds reports unresolved team personas", () => {
assert.equal(
countMissingPersonaIds(
["persona:available", "persona:missing-a", "persona:missing-b"],
[createPersona("persona:available")],
),
2,
);
});
test("filterAvailablePersonaIds drops missing personas at submit time", () => {
assert.deepEqual(
filterAvailablePersonaIds(
["persona:missing", "persona:available"],
[createPersona("persona:available")],
),
["persona:available"],
);
});
test("orderPersonasByInitiallySelected keeps initially selected personas at top", () => {
const personas = [
createPersona("persona:michelangelo"),
createPersona("persona:milhouse"),
createPersona("persona:ned"),
createPersona("persona:raphael"),
];
const ordered = orderPersonasByInitiallySelected(personas, [
"persona:milhouse",
"persona:ned",
"persona:missing",
]);
assert.deepEqual(
ordered.map((persona) => persona.id),
[
"persona:milhouse",
"persona:ned",
"persona:michelangelo",
"persona:raphael",
],
);
});

View File

@@ -1,45 +0,0 @@
import type { AgentPersona } from "@/shared/api/types";
function getAvailablePersonaIds(personas: AgentPersona[]): Set<string> {
return new Set(personas.map((persona) => persona.id));
}
export function copySelectedPersonaIds(personaIds: string[]): string[] {
return [...personaIds];
}
export function countMissingPersonaIds(
personaIds: string[],
personas: AgentPersona[],
): number {
const availablePersonaIds = getAvailablePersonaIds(personas);
return personaIds.filter((personaId) => !availablePersonaIds.has(personaId))
.length;
}
export function filterAvailablePersonaIds(
personaIds: string[],
personas: AgentPersona[],
): string[] {
const availablePersonaIds = getAvailablePersonaIds(personas);
return personaIds.filter((personaId) => availablePersonaIds.has(personaId));
}
export function orderPersonasByInitiallySelected(
personas: AgentPersona[],
initialSelectedPersonaIds: string[],
): AgentPersona[] {
const selectedIds = new Set(initialSelectedPersonaIds);
const selected: AgentPersona[] = [];
const unselected: AgentPersona[] = [];
for (const persona of personas) {
if (selectedIds.has(persona.id)) {
selected.push(persona);
} else {
unselected.push(persona);
}
}
return [...selected, ...unselected];
}

View File

@@ -24,17 +24,32 @@ function createPersona(
};
}
function createTeam(personaIds) {
function createTeam(personaIds, agentPubkeys = []) {
return {
id: "team-1",
name: "Alpha Team",
description: "Original description",
personaIds,
agentPubkeys,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
};
}
function createAgent(pubkey, name, systemPrompt = "Prompt") {
return {
pubkey,
name,
personaId: null,
avatarUrl: null,
systemPrompt,
model: null,
provider: null,
envVars: {},
status: "stopped",
};
}
test("buildTeamImportPlan categorizes updated, added, and missing members", () => {
const personas = [
createPersona("persona-1", "Alice", "Old prompt"),
@@ -58,7 +73,7 @@ test("buildTeamImportPlan categorizes updated, added, and missing members", () =
],
};
const plan = buildTeamImportPlan({ team, personas, preview });
const plan = buildTeamImportPlan({ team, personas, agents: [], preview });
assert.equal(plan.membersToUpdate.length, 1);
assert.equal(plan.membersToUpdate[0]?.existing.id, "persona-1");
@@ -97,7 +112,7 @@ test("buildTeamImportPlan pairs duplicate names in stable order", () => {
],
};
const plan = buildTeamImportPlan({ team, personas, preview });
const plan = buildTeamImportPlan({ team, personas, agents: [], preview });
assert.equal(plan.matchedMembers.length, 2);
assert.equal(plan.matchedMembers[0]?.existing.id, "persona-1");
@@ -125,7 +140,34 @@ test("buildTeamImportPlan reports unresolved persona ids from stale team members
],
};
const plan = buildTeamImportPlan({ team, personas, preview });
const plan = buildTeamImportPlan({ team, personas, agents: [], preview });
assert.deepEqual(plan.unresolvedPersonaIds, ["persona-missing"]);
assert.deepEqual(plan.unresolvedMemberIds, ["persona-missing"]);
});
test("buildTeamImportPlan matches agent members by name", () => {
const team = createTeam([], ["agent-pk-1", "agent-pk-2"]);
const agents = [
createAgent("agent-pk-1", "Honey", "Old prompt"),
createAgent("agent-pk-2", "Waxwing", "Waxwing prompt"),
];
const preview = {
name: "Alpha Team",
description: "Original description",
personas: [
{
display_name: "Honey",
system_prompt: "New prompt",
avatar_url: null,
},
],
};
const plan = buildTeamImportPlan({ team, personas: [], agents, preview });
assert.equal(plan.membersToUpdate.length, 1);
assert.equal(plan.membersToUpdate[0]?.existing.kind, "agent");
assert.equal(plan.membersToUpdate[0]?.existing.id, "agent-pk-1");
assert.equal(plan.missingMembers.length, 1);
assert.equal(plan.missingMembers[0]?.existing.id, "agent-pk-2");
});

View File

@@ -1,15 +1,25 @@
import type { ParsedTeamPreview } from "@/shared/api/tauriTeams";
import type { AgentPersona, AgentTeam } from "@/shared/api/types";
import type { AgentPersona, AgentTeam, ManagedAgent } from "@/shared/api/types";
type ImportedTeamPersona = ParsedTeamPreview["personas"][number];
/** A team member resolved from either an agent record or a pack persona. */
export type ExistingTeamMember = {
kind: "agent" | "persona";
/** Agent pubkey or persona id. */
id: string;
displayName: string;
avatarUrl: string | null;
systemPrompt: string;
};
type LineChangeCounts = {
addedLines: number;
removedLines: number;
};
export type TeamImportMatchedMember = {
existing: AgentPersona;
existing: ExistingTeamMember;
imported: ImportedTeamPersona;
importedIndex: number;
hasChanges: boolean;
@@ -24,7 +34,7 @@ export type TeamImportNewMember = {
};
export type TeamImportMissingMember = {
existing: AgentPersona;
existing: ExistingTeamMember;
removedLines: number;
};
@@ -33,7 +43,7 @@ export type TeamImportPlan = {
membersToUpdate: TeamImportMatchedMember[];
newMembers: TeamImportNewMember[];
missingMembers: TeamImportMissingMember[];
unresolvedPersonaIds: string[];
unresolvedMemberIds: string[];
teamNameChanged: boolean;
teamDescriptionChanged: boolean;
};
@@ -41,6 +51,7 @@ export type TeamImportPlan = {
type BuildTeamImportPlanInput = {
team: AgentTeam;
personas: AgentPersona[];
agents: ManagedAgent[];
preview: ParsedTeamPreview;
};
@@ -66,11 +77,11 @@ function normalizePromptLines(prompt: string): string[] {
return lines;
}
function existingPersonaSnapshotLines(persona: AgentPersona): string[] {
function existingMemberSnapshotLines(member: ExistingTeamMember): string[] {
return [
`display_name:${normalizeOptionalText(persona.displayName)}`,
`avatar_url:${normalizeAvatar(persona.avatarUrl) ?? ""}`,
...normalizePromptLines(persona.systemPrompt).map(
`display_name:${normalizeOptionalText(member.displayName)}`,
`avatar_url:${normalizeAvatar(member.avatarUrl) ?? ""}`,
...normalizePromptLines(member.systemPrompt).map(
(line) => `prompt:${line}`,
),
];
@@ -151,12 +162,12 @@ function countLineChanges(
};
}
function getPersonaLineChangeCounts(
existing: AgentPersona,
function getMemberLineChangeCounts(
existing: ExistingTeamMember,
imported: ImportedTeamPersona,
): LineChangeCounts {
return countLineChanges(
existingPersonaSnapshotLines(existing),
existingMemberSnapshotLines(existing),
importedPersonaSnapshotLines(imported),
);
}
@@ -165,28 +176,54 @@ function getImportedPersonaLineCount(persona: ImportedTeamPersona): number {
return importedPersonaSnapshotLines(persona).length;
}
function getExistingPersonaLineCount(persona: AgentPersona): number {
return existingPersonaSnapshotLines(persona).length;
function getExistingMemberLineCount(member: ExistingTeamMember): number {
return existingMemberSnapshotLines(member).length;
}
export function buildTeamImportPlan({
team,
personas,
agents,
preview,
}: BuildTeamImportPlanInput): TeamImportPlan {
const personaById = new Map(personas.map((persona) => [persona.id, persona]));
const agentByPubkey = new Map(agents.map((agent) => [agent.pubkey, agent]));
const matchedImportedIndexes = new Set<number>();
const matchedMembers: TeamImportMatchedMember[] = [];
const missingMembers: { existing: AgentPersona }[] = [];
const unresolvedPersonaIds: string[] = [];
const missingMembers: { existing: ExistingTeamMember }[] = [];
const unresolvedMemberIds: string[] = [];
const existingMembers: ExistingTeamMember[] = [];
for (const personaId of team.personaIds) {
const existing = personaById.get(personaId);
if (!existing) {
unresolvedPersonaIds.push(personaId);
const persona = personaById.get(personaId);
if (!persona) {
unresolvedMemberIds.push(personaId);
continue;
}
existingMembers.push({
kind: "persona",
id: persona.id,
displayName: persona.displayName,
avatarUrl: persona.avatarUrl,
systemPrompt: persona.systemPrompt,
});
}
for (const pubkey of team.agentPubkeys) {
const agent = agentByPubkey.get(pubkey);
if (!agent) {
unresolvedMemberIds.push(pubkey);
continue;
}
existingMembers.push({
kind: "agent",
id: agent.pubkey,
displayName: agent.name,
avatarUrl: agent.avatarUrl,
systemPrompt: agent.systemPrompt ?? "",
});
}
for (const existing of existingMembers) {
const existingName = normalizeName(existing.displayName);
const importedIndex = preview.personas.findIndex(
(imported, index) =>
@@ -201,7 +238,7 @@ export function buildTeamImportPlan({
matchedImportedIndexes.add(importedIndex);
const imported = preview.personas[importedIndex];
const { addedLines, removedLines } = getPersonaLineChangeCounts(
const { addedLines, removedLines } = getMemberLineChangeCounts(
existing,
imported,
);
@@ -234,9 +271,9 @@ export function buildTeamImportPlan({
newMembers,
missingMembers: missingMembers.map((member) => ({
...member,
removedLines: getExistingPersonaLineCount(member.existing),
removedLines: getExistingMemberLineCount(member.existing),
})),
unresolvedPersonaIds,
unresolvedMemberIds,
teamNameChanged: normalizeOptionalText(team.name) !== preview.name,
teamDescriptionChanged:
normalizeOptionalText(team.description) !==

View File

@@ -1,44 +0,0 @@
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
import type { AgentPersona, ManagedAgent } from "@/shared/api/types";
type PersonaGroup = { persona: AgentPersona; agents: ManagedAgent[] };
export function buildUnifiedGroups(
personas: AgentPersona[],
agents: ManagedAgent[],
) {
const byPersonaId = new Map<string, ManagedAgent[]>();
const ungrouped: ManagedAgent[] = [];
for (const agent of agents) {
if (!agent.personaId) {
ungrouped.push(agent);
} else {
const list = byPersonaId.get(agent.personaId) ?? [];
list.push(agent);
byPersonaId.set(agent.personaId, list);
}
}
const matched = new Set<string>();
const groups: PersonaGroup[] = personas.map((persona) => {
matched.add(persona.id);
return { persona, agents: byPersonaId.get(persona.id) ?? [] };
});
const unknown: ManagedAgent[] = [];
for (const [id, list] of byPersonaId) {
if (!matched.has(id)) unknown.push(...list);
}
return { groups, ungrouped, unknown };
}
export function pickProfileAgent(agents: ManagedAgent[]) {
return [...agents].sort((left, right) => {
const activeDiff =
Number(isManagedAgentActive(right)) - Number(isManagedAgentActive(left));
if (activeDiff !== 0) return activeDiff;
return left.name.localeCompare(right.name);
})[0];
}

View File

@@ -2,8 +2,7 @@ import * as React from "react";
import {
type AttachManagedAgentToChannelResult,
useAvailableAcpRuntimes,
useCreateManagedAgentMutation,
useExportAgentJsonMutation,
useManagedAgentLogQuery,
useManagedAgentsQuery,
useRelayAgentsQuery,
@@ -17,15 +16,17 @@ import { usePresenceQuery } from "@/features/presence/hooks";
import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore";
import { useActiveAgentTurnsBridge } from "@/features/agents/activeAgentTurnsStore";
import type {
AcpRuntime,
AcpRuntimeCatalogEntry,
AgentPersona,
AgentTemplate,
Channel,
CreateManagedAgentInput,
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,
@@ -33,7 +34,13 @@ import {
startManagedAgentWithRules,
stopManagedAgentWithRules,
} from "../lib/managedAgentControlActions";
import { resolvePersonaRuntime } from "../lib/resolvePersonaRuntime";
import {
blankAgentDraft,
duplicateAgentDraft,
importAgentDraft,
templateAgentDraft,
type AgentDraft,
} from "./agentDraft";
export function useManagedAgentActions() {
const relayAgentsQuery = useRelayAgentsQuery();
@@ -43,18 +50,17 @@ export function useManagedAgentActions() {
const startMutation = useStartManagedAgentMutation();
const stopMutation = useStopManagedAgentMutation();
const deleteMutation = useDeleteManagedAgentMutation();
const createAgentMutation = useCreateManagedAgentMutation();
const availableRuntimesQuery = useAvailableAcpRuntimes();
const startOnLaunchMutation = useSetManagedAgentStartOnAppLaunchMutation();
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
const exportAgentJsonMutation = useExportAgentJsonMutation();
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 [startingPersonaIds, setStartingPersonaIds] = React.useState<
ReadonlySet<string>
>(() => new Set());
const startingPersonaIdsRef = React.useRef(new Set<string>());
const [logAgentPubkey, setLogAgentPubkey] = React.useState<string | null>(
null,
);
@@ -172,96 +178,78 @@ export function useManagedAgentActions() {
}
}
async function getAvailableRuntimesForStart() {
if (availableRuntimesQuery.isFetched) {
return availableRuntimesQuery.data ?? [];
}
// ── Create Agent wizard ────────────────────────────────────────────────
const result = await availableRuntimesQuery.refetch();
return filterAvailableRuntimes(result.data);
function openCreateStart() {
clearFeedback();
setIsCreateStartOpen(true);
}
function setPersonaStartPending(personaId: string, pending: boolean) {
const next = new Set(startingPersonaIdsRef.current);
if (pending) {
next.add(personaId);
} else {
next.delete(personaId);
}
startingPersonaIdsRef.current = next;
setStartingPersonaIds(next);
function openCreateWithDraft(draft: AgentDraft) {
clearFeedback();
setIsCreateStartOpen(false);
setCreateDraft(draft);
}
async function handleStartPersona(persona: AgentPersona) {
if (startingPersonaIdsRef.current.has(persona.id)) {
return;
}
setPersonaStartPending(persona.id, true);
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 runtimes = await getAvailableRuntimesForStart();
const defaultRuntime = runtimes[0] ?? null;
const { runtime, warnings, isOverridden } = resolvePersonaRuntime(
persona.runtime,
runtimes,
defaultRuntime,
);
if (!runtime) {
throw new Error("No available runtime found for this agent.");
}
if (isOverridden) {
throw new Error(
warnings[0] ??
"This agent's configured runtime is not available. Install the runtime or edit the agent before starting it.",
);
}
const input: CreateManagedAgentInput = {
name: persona.displayName,
acpCommand: "buzz-acp",
agentCommand: runtime.command,
agentArgs: runtime.defaultArgs,
mcpCommand: runtime.mcpCommand ?? "",
personaId: persona.id,
systemPrompt: persona.systemPrompt,
avatarUrl: persona.avatarUrl ?? undefined,
model: persona.model ?? undefined,
envVars: persona.envVars,
spawnAfterCreate: true,
startOnAppLaunch: true,
backend: { type: "local" },
harnessOverride: !persona.runtime || persona.runtime === runtime.id,
};
const created = await createAgentMutation.mutateAsync(input);
setCreatedAgent(created);
const notices = [...warnings];
if (created.spawnError) {
setActionErrorMessage(created.spawnError);
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 {
notices.push(`Started ${created.agent.name}.`);
setActionErrorMessage("No valid agents found in file.");
}
if (created.profileSyncError) {
notices.push(created.profileSyncError);
}
if (notices.length > 0) {
setActionNoticeMessage(notices.join(" "));
}
void managedAgentsQuery.refetch();
void relayAgentsQuery.refetch();
} catch (error) {
setActionErrorMessage(
error instanceof Error ? error.message : "Failed to start agent.",
error instanceof Error ? error.message : "Failed to parse agent file.",
);
} finally {
setPersonaStartPending(persona.id, false);
}
}
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.",
);
},
});
}
async function getChannelsForAction() {
if (channelsQuery.data) {
return channelsQuery.data;
@@ -445,10 +433,10 @@ export function useManagedAgentActions() {
}
const isPending =
createAgentMutation.isPending ||
startMutation.isPending ||
stopMutation.isPending ||
startOnLaunchMutation.isPending ||
exportAgentJsonMutation.isPending ||
deleteMutation.isPending;
const startingAgentPubkey =
startMutation.isPending && typeof startMutation.variables === "string"
@@ -465,8 +453,13 @@ export function useManagedAgentActions() {
channelIdToName,
channelsByPubkey,
isPending,
isCreateOpen,
setIsCreateOpen,
isCreateStartOpen,
setIsCreateStartOpen,
createDraft,
setCreateDraft,
batchImportResult,
setBatchImportResult,
batchImportFileName,
agentToAddToChannel,
setAgentToAddToChannel,
createdAgent,
@@ -478,9 +471,14 @@ export function useManagedAgentActions() {
actionErrorMessage,
setActionErrorMessage,
startingAgentPubkey,
startingPersonaIds,
openCreateStart,
handlePickBlank,
handlePickTemplate,
openDuplicateAgent,
handleImportAgentFile,
handleBatchImportComplete,
handleExportAgent,
handleStart,
handleStartPersona,
handleStop,
handleDelete,
handleToggleStartOnAppLaunch,
@@ -491,11 +489,3 @@ export function useManagedAgentActions() {
refetchRelayAgents: () => void relayAgentsQuery.refetch(),
};
}
function filterAvailableRuntimes(
runtimes: readonly AcpRuntimeCatalogEntry[] | undefined,
): AcpRuntime[] {
return (runtimes ?? []).filter(
(runtime): runtime is AcpRuntime => runtime.availability === "available",
);
}

View File

@@ -1,432 +0,0 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
personasQueryKey,
useAcpRuntimesQuery,
useCreateManagedAgentMutation,
useCreatePersonaMutation,
useDeletePersonaMutation,
useExportPersonaJsonMutation,
usePersonasQuery,
useSetPersonaActiveMutation,
useUpdatePersonaMutation,
} from "@/features/agents/hooks";
import { getPersonaLibraryState } from "@/features/agents/lib/catalog";
import {
parsePersonaFiles,
type ParsePersonaFilesResult,
} from "@/shared/api/tauriPersonas";
import { isSingleItemFile } from "@/shared/lib/fileMagic";
import type {
AcpRuntime,
AgentPersona,
CreateManagedAgentInput,
CreateManagedAgentResponse,
CreatePersonaInput,
UpdatePersonaInput,
} from "@/shared/api/types";
import {
createPersonaDialogState,
duplicatePersonaDialogState,
editPersonaDialogState,
importPersonaDialogState,
type PersonaDialogState,
} from "./personaDialogState";
import { resolveManagedAgentAvatarUrl } from "./managedAgentAvatar";
import { usePersonaImportActions } from "./usePersonaImportActions";
type PersonaFeedbackSurface = "catalog" | "library";
const PERSONA_CATALOG_VISIBILITY_STORAGE_KEY =
"buzz-persona-catalog-visibility-v1";
function readSharedCatalogPersonaIds(): string[] {
if (typeof window === "undefined") {
return [];
}
try {
const raw = window.localStorage.getItem(
PERSONA_CATALOG_VISIBILITY_STORAGE_KEY,
);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((id): id is string => typeof id === "string");
} catch {
return [];
}
}
function writeSharedCatalogPersonaIds(ids: string[]) {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(
PERSONA_CATALOG_VISIBILITY_STORAGE_KEY,
JSON.stringify(ids),
);
} catch {
// Catalog visibility is a local convenience setting; ignore storage failures.
}
}
export function usePersonaActions() {
const queryClient = useQueryClient();
const personasQuery = usePersonasQuery();
const [shouldLoadAcpRuntimes, setShouldLoadAcpRuntimes] =
React.useState(false);
const acpRuntimesQuery = useAcpRuntimesQuery({
enabled: shouldLoadAcpRuntimes,
});
const createAgentMutation = useCreateManagedAgentMutation();
const createPersonaMutation = useCreatePersonaMutation();
const updatePersonaMutation = useUpdatePersonaMutation();
const deletePersonaMutation = useDeletePersonaMutation();
const setPersonaActiveMutation = useSetPersonaActiveMutation();
const exportPersonaJsonMutation = useExportPersonaJsonMutation();
const [personaDialogState, setPersonaDialogState] =
React.useState<PersonaDialogState | null>(null);
const [personaToDelete, setPersonaToDelete] =
React.useState<AgentPersona | null>(null);
const [personaToShare, setPersonaToShare] =
React.useState<AgentPersona | null>(null);
const [isCatalogDialogOpen, setIsCatalogDialogOpen] = React.useState(false);
const [sharedCatalogPersonaIds, setSharedCatalogPersonaIds] = React.useState<
string[]
>(readSharedCatalogPersonaIds);
const [batchImportResult, setBatchImportResult] =
React.useState<ParsePersonaFilesResult | null>(null);
const [batchImportFileName, setBatchImportFileName] = React.useState("");
const [personaNoticeMessage, setPersonaNoticeMessage] = React.useState<
string | null
>(null);
const [personaErrorMessage, setPersonaErrorMessage] = React.useState<
string | null
>(null);
const [personaFeedbackSurface, setPersonaFeedbackSurface] =
React.useState<PersonaFeedbackSurface>("library");
const [createdAgent, setCreatedAgent] =
React.useState<CreateManagedAgentResponse | null>(null);
const [isPersonaSubmitPending, setIsPersonaSubmitPending] =
React.useState(false);
const personas = personasQuery.data ?? [];
const sharedCatalogPersonaIdSet = React.useMemo(
() => new Set(sharedCatalogPersonaIds),
[sharedCatalogPersonaIds],
);
const availableRuntimes = React.useMemo(
() =>
(acpRuntimesQuery.data ?? []).filter(
(runtime): runtime is AcpRuntime =>
runtime.availability === "available",
),
[acpRuntimesQuery.data],
);
const { catalogPersonas, libraryPersonas, personaLabelsById } = React.useMemo(
() => getPersonaLibraryState(personas, sharedCatalogPersonaIdSet),
[personas, sharedCatalogPersonaIdSet],
);
const personaImportActions = usePersonaImportActions(personas, {
clearPersonaFeedback: () => clearFeedback("library"),
setPersonaNoticeMessage,
setPersonaErrorMessage,
setPersonaDialogState,
});
function clearFeedback(
surface: PersonaFeedbackSurface = personaFeedbackSurface,
) {
setPersonaFeedbackSurface(surface);
setPersonaNoticeMessage(null);
setPersonaErrorMessage(null);
}
async function handleSubmit(input: CreatePersonaInput | UpdatePersonaInput) {
if (isPersonaSubmitPending) {
return;
}
clearFeedback("library");
setIsPersonaSubmitPending(true);
try {
if ("id" in input) {
await updatePersonaMutation.mutateAsync(input);
setPersonaNoticeMessage(`Updated ${input.displayName}.`);
} else {
const runtime = availableRuntimes.find(
(candidate) => candidate.id === input.runtime,
);
if (!runtime) {
setPersonaErrorMessage(
"Choose an available provider for this agent.",
);
return;
}
const avatarUrl = await resolveManagedAgentAvatarUrl(
input.avatarUrl,
undefined,
runtime.avatarUrl,
);
const persona = await createPersonaMutation.mutateAsync({
...input,
avatarUrl,
});
const agentInput: CreateManagedAgentInput = {
name: persona.displayName,
acpCommand: "buzz-acp",
agentCommand: runtime.command,
agentArgs: runtime.defaultArgs,
mcpCommand: runtime.mcpCommand ?? "",
personaId: persona.id,
harnessOverride: true,
systemPrompt: persona.systemPrompt,
avatarUrl: persona.avatarUrl ?? avatarUrl,
model: persona.model ?? undefined,
spawnAfterCreate: true,
startOnAppLaunch: true,
backend: { type: "local" },
};
try {
const created = await createAgentMutation.mutateAsync(agentInput);
setCreatedAgent(created);
if (created.spawnError) {
setPersonaErrorMessage(
`${persona.displayName} was created, but it did not start: ${created.spawnError}`,
);
} else {
setPersonaNoticeMessage(
`Created and started ${created.agent.name}.`,
);
}
if (created.profileSyncError) {
setPersonaErrorMessage(
`${created.agent.name} was created, but profile sync failed: ${created.profileSyncError}`,
);
}
} catch (error) {
setPersonaErrorMessage(
error instanceof Error
? `${persona.displayName} was created, but the agent instance could not be created: ${error.message}`
: `${persona.displayName} was created, but the agent instance could not be created.`,
);
}
}
setPersonaDialogState(null);
} catch (error) {
setPersonaErrorMessage(
error instanceof Error ? error.message : "Failed to save persona.",
);
} finally {
setIsPersonaSubmitPending(false);
}
}
async function handleDelete(persona: AgentPersona) {
clearFeedback("library");
try {
await deletePersonaMutation.mutateAsync(persona.id);
setPersonaNoticeMessage(`Deleted ${persona.displayName}.`);
setPersonaToDelete(null);
} catch (error) {
setPersonaErrorMessage(
error instanceof Error ? error.message : "Failed to delete persona.",
);
}
}
async function handleSetActive(
persona: AgentPersona,
active: boolean,
surface: PersonaFeedbackSurface,
) {
clearFeedback(surface);
try {
await setPersonaActiveMutation.mutateAsync({ id: persona.id, active });
setPersonaNoticeMessage(
active
? `Selected ${persona.displayName} for My Agents.`
: `Deselected ${persona.displayName} from My Agents.`,
);
} catch (error) {
setPersonaErrorMessage(
error instanceof Error
? error.message
: active
? "Failed to select persona for My Agents."
: "Failed to deselect persona from My Agents.",
);
}
}
async function handleImportFile(fileBytes: number[], fileName: string) {
clearFeedback("library");
try {
const result = await parsePersonaFiles(fileBytes, fileName);
if (
isSingleItemFile(fileBytes, fileName) &&
result.personas.length === 1
) {
setShouldLoadAcpRuntimes(true);
setPersonaDialogState(importPersonaDialogState(result.personas[0]));
} else if (result.personas.length > 0) {
setBatchImportResult(result);
setBatchImportFileName(fileName);
} else {
setPersonaErrorMessage("No valid personas found in file.");
}
} catch (err) {
setPersonaErrorMessage(
err instanceof Error ? err.message : "Failed to parse persona file.",
);
}
}
function handleExport(persona: AgentPersona) {
clearFeedback("library");
exportPersonaJsonMutation.mutate(persona.id, {
onSuccess: (saved) => {
if (saved) {
setPersonaNoticeMessage(`Exported ${persona.displayName}.`);
}
},
onError: (error) => {
setPersonaErrorMessage(
error instanceof Error ? error.message : "Failed to export persona.",
);
},
});
}
function handleBatchImportComplete(count: number) {
clearFeedback("library");
setBatchImportResult(null);
setPersonaNoticeMessage(
`Imported ${count} persona${count !== 1 ? "s" : ""}.`,
);
void queryClient.invalidateQueries({ queryKey: personasQueryKey });
}
function openCreate() {
clearFeedback("library");
setShouldLoadAcpRuntimes(true);
setPersonaDialogState(createPersonaDialogState());
}
function openEdit(persona: AgentPersona) {
clearFeedback("library");
setShouldLoadAcpRuntimes(true);
setPersonaDialogState(editPersonaDialogState(persona));
}
function openDuplicate(persona: AgentPersona) {
clearFeedback("library");
setShouldLoadAcpRuntimes(true);
setPersonaDialogState(duplicatePersonaDialogState(persona));
}
function openCatalog() {
clearFeedback("catalog");
setIsCatalogDialogOpen(true);
}
function openDelete(persona: AgentPersona) {
clearFeedback("library");
setPersonaToDelete(persona);
}
function openShare(persona: AgentPersona) {
clearFeedback("library");
setPersonaToShare(persona);
}
function setPersonaCatalogVisibility(
persona: AgentPersona,
visible: boolean,
) {
if (persona.isBuiltIn) {
return;
}
clearFeedback("library");
setSharedCatalogPersonaIds((current) => {
const next = new Set(current);
if (visible) {
next.add(persona.id);
} else {
next.delete(persona.id);
}
const ids = Array.from(next);
writeSharedCatalogPersonaIds(ids);
return ids;
});
}
const isPending =
isPersonaSubmitPending ||
createPersonaMutation.isPending ||
createAgentMutation.isPending ||
updatePersonaMutation.isPending ||
deletePersonaMutation.isPending ||
setPersonaActiveMutation.isPending ||
exportPersonaJsonMutation.isPending;
return {
personasQuery,
acpRuntimesQuery,
createPersonaMutation,
updatePersonaMutation,
setPersonaActiveMutation,
catalogPersonas,
libraryPersonas,
personaLabelsById,
isPending,
personaDialogState,
setPersonaDialogState,
personaToDelete,
setPersonaToDelete,
personaToShare,
setPersonaToShare,
isCatalogDialogOpen,
setIsCatalogDialogOpen,
batchImportResult,
setBatchImportResult,
batchImportFileName,
personaNoticeMessage,
personaErrorMessage,
personaFeedbackSurface,
createdAgent,
setCreatedAgent,
personaImportActions,
handleSubmit,
handleDelete,
handleSetActive,
handleImportFile,
handleExport,
handleBatchImportComplete,
openCreate,
openEdit,
openDuplicate,
openCatalog,
openDelete,
openShare,
setPersonaCatalogVisibility,
sharedCatalogPersonaIdSet,
clearFeedback,
};
}

View File

@@ -1,160 +0,0 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { personasQueryKey } from "@/features/agents/hooks";
import {
parsePersonaFiles,
updatePersona as updatePersonaApi,
type ParsedPersonaPreview,
} from "@/shared/api/tauriPersonas";
import type { AgentPersona } from "@/shared/api/types";
import { resolveManagedAgentAvatarUrl } from "./managedAgentAvatar";
import { buildPersonaImportPlan } from "./personaImportPlan";
import { buildPersonaImportUpdateInput } from "./personaImportUpdateInput";
import {
editPersonaDialogState,
type PersonaDialogState,
} from "./personaDialogState";
type FeedbackCallbacks = {
clearPersonaFeedback: () => void;
setPersonaNoticeMessage: (message: string | null) => void;
setPersonaErrorMessage: (message: string | null) => void;
setPersonaDialogState: (state: PersonaDialogState | null) => void;
};
export function usePersonaImportActions(
libraryPersonas: AgentPersona[],
feedback: FeedbackCallbacks,
) {
const queryClient = useQueryClient();
const [personaImportTarget, setPersonaImportTarget] =
React.useState<AgentPersona | null>(null);
const [personaImportTargetPreview, setPersonaImportTargetPreview] =
React.useState<{ preview: ParsedPersonaPreview; fileName: string } | null>(
null,
);
const [isApplyingPersonaImportUpdate, setIsApplyingPersonaImportUpdate] =
React.useState(false);
async function handleEditDialogImportUpdateFile(
personaId: string,
fileBytes: number[],
fileName: string,
) {
feedback.clearPersonaFeedback();
const persona = libraryPersonas.find(
(candidate) => candidate.id === personaId,
);
if (!persona) {
const message = "Persona not found. Refresh and try again.";
feedback.setPersonaErrorMessage(message);
throw new Error(message);
}
try {
const result = await parsePersonaFiles(fileBytes, fileName);
if (result.personas.length === 0) {
const message = "No valid personas found in file.";
feedback.setPersonaErrorMessage(message);
throw new Error(message);
}
const preview = result.personas[0];
setPersonaImportTarget(persona);
setPersonaImportTargetPreview({ preview, fileName });
feedback.setPersonaDialogState(null);
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to parse persona file.";
feedback.setPersonaErrorMessage(message);
throw err instanceof Error ? err : new Error(message);
}
}
function closeImportUpdateDialog() {
setPersonaImportTarget(null);
setPersonaImportTargetPreview(null);
setIsApplyingPersonaImportUpdate(false);
}
function clearImportUpdateAndReturnToEdit() {
if (!personaImportTarget) {
closeImportUpdateDialog();
return;
}
const persona = personaImportTarget;
closeImportUpdateDialog();
feedback.clearPersonaFeedback();
feedback.setPersonaDialogState(editPersonaDialogState(persona));
}
async function handleImportUpdateApply({
selectedFields,
}: {
selectedFields: string[];
}) {
if (!personaImportTarget || !personaImportTargetPreview) {
throw new Error("No persona import update is currently open.");
}
feedback.clearPersonaFeedback();
setIsApplyingPersonaImportUpdate(true);
const plan = buildPersonaImportPlan({
persona: personaImportTarget,
preview: personaImportTargetPreview.preview,
});
const preview = personaImportTargetPreview.preview;
const existing = personaImportTarget;
try {
const updateInput = buildPersonaImportUpdateInput({
existing,
preview,
selectedFields,
});
if (selectedFields.includes("avatarUrl")) {
updateInput.avatarUrl = await resolveManagedAgentAvatarUrl(
updateInput.avatarUrl,
undefined,
existing.avatarUrl,
);
}
await updatePersonaApi(updateInput);
const updatedFieldCount = plan.fields.filter((field) =>
selectedFields.includes(field.field),
).length;
feedback.setPersonaNoticeMessage(
`Updated "${updateInput.displayName}" from import. ${updatedFieldCount} field${updatedFieldCount === 1 ? "" : "s"} updated.`,
);
closeImportUpdateDialog();
await queryClient.invalidateQueries({ queryKey: personasQueryKey });
} catch (error) {
const message =
error instanceof Error
? error.message
: "Failed to apply imported persona update.";
feedback.setPersonaErrorMessage(message);
throw error instanceof Error ? error : new Error(message);
} finally {
setIsApplyingPersonaImportUpdate(false);
}
}
return {
personaImportTarget,
personaImportTargetPreview,
isApplyingPersonaImportUpdate,
handleEditDialogImportUpdateFile,
handleImportUpdateApply,
closeImportUpdateDialog,
clearImportUpdateAndReturnToEdit,
};
}

View File

@@ -5,8 +5,11 @@ import {
managedAgentsQueryKey,
personasQueryKey,
teamsQueryKey,
useAvailableAcpRuntimes,
useCreateTeamMutation,
useDeleteTeamMutation,
useManagedAgentsQuery,
usePersonasQuery,
useTeamsQuery,
useUpdateTeamMutation,
} from "@/features/agents/hooks";
@@ -20,19 +23,21 @@ import {
pickTeamDirectory,
syncTeamDirectory,
} from "@/shared/api/tauriTeams";
import { deletePersona, updatePersona } from "@/shared/api/tauriPersonas";
import {
createPersona,
deletePersona,
updatePersona,
} from "@/shared/api/tauriPersonas";
createManagedAgent,
deleteManagedAgent,
updateManagedAgent,
} from "@/shared/api/tauri";
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import type {
AgentPersona,
AcpRuntime,
AgentTeam,
Channel,
CreateTeamInput,
UpdateTeamInput,
} from "@/shared/api/types";
import type { RemovedTeamMemberRef } from "./TeamDialog";
import { buildTeamImportPlan } from "./teamImportPlan";
type TeamDialogState = {
@@ -53,11 +58,10 @@ type RefetchCallbacks = {
};
type TeamImportUpdateApplyInput = {
personas: AgentPersona[];
updateTeamInfo: boolean;
selectedUpdatedPersonaIds: string[];
selectedUpdatedMemberIds: string[];
selectedNewMemberIndexes: number[];
missingPersonaIdsToRemove: string[];
missingMemberIdsToRemove: string[];
deleteRemovedAgents: boolean;
};
@@ -67,6 +71,9 @@ export function useTeamActions(
) {
const queryClient = useQueryClient();
const teamsQuery = useTeamsQuery();
const personasQuery = usePersonasQuery();
const managedAgentsQuery = useManagedAgentsQuery();
const availableRuntimesQuery = useAvailableAcpRuntimes();
const createTeamMutation = useCreateTeamMutation();
const updateTeamMutation = useUpdateTeamMutation();
const deleteTeamMutation = useDeleteTeamMutation();
@@ -97,6 +104,16 @@ export function useTeamActions(
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);
@@ -158,12 +175,13 @@ export function useTeamActions(
actions.setActionErrorMessage(null);
setTeamDialogState({
title: "Create team",
description: "Group personas together for quick deployment to channels.",
description: "Group agents together for quick deployment to channels.",
submitLabel: "Create team",
initialValues: {
name: "",
description: "",
personaIds: [],
agentPubkeys: [],
},
});
}
@@ -179,6 +197,7 @@ export function useTeamActions(
name: `${team.name} copy`,
description: team.description ?? "",
personaIds: [...team.personaIds],
agentPubkeys: [...team.agentPubkeys],
},
});
}
@@ -315,11 +334,10 @@ export function useTeamActions(
}
async function handleTeamImportUpdateApply({
personas,
updateTeamInfo,
selectedUpdatedPersonaIds,
selectedUpdatedMemberIds,
selectedNewMemberIndexes,
missingPersonaIdsToRemove,
missingMemberIdsToRemove,
deleteRemovedAgents,
}: TeamImportUpdateApplyInput) {
if (!teamImportTarget || !teamImportTargetPreview) {
@@ -330,80 +348,101 @@ export function useTeamActions(
actions.setActionErrorMessage(null);
setIsApplyingTeamImportUpdate(true);
const personas = personasQuery.data ?? [];
const agents = managedAgentsQuery.data ?? [];
const plan = buildTeamImportPlan({
team: teamImportTarget,
personas,
agents,
preview: teamImportTargetPreview.preview,
});
const selectedUpdatedPersonaIdSet = new Set(selectedUpdatedPersonaIds);
const selectedUpdatedMemberIdSet = new Set(selectedUpdatedMemberIds);
const selectedNewMemberIndexSet = new Set(selectedNewMemberIndexes);
const removePersonaIdSet = new Set(missingPersonaIdsToRemove);
const removeMemberIdSet = new Set(missingMemberIdsToRemove);
try {
let updatedMembersCount = 0;
for (const member of plan.membersToUpdate) {
if (!selectedUpdatedPersonaIdSet.has(member.existing.id)) {
if (!selectedUpdatedMemberIdSet.has(member.existing.id)) {
continue;
}
await updatePersona({
id: member.existing.id,
displayName: member.imported.display_name,
systemPrompt: member.imported.system_prompt,
avatarUrl: member.imported.avatar_url ?? undefined,
runtime: member.existing.runtime ?? undefined,
model: member.existing.model ?? undefined,
namePool: [...member.existing.namePool],
});
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;
}
const createdPersonaIdsByImportedIndex = new Map<number, string>();
// 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 created = await createPersona({
displayName: member.imported.display_name,
systemPrompt: member.imported.system_prompt,
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" },
});
createdPersonaIdsByImportedIndex.set(member.importedIndex, created.id);
}
const matchedPersonaIdsByImportedIndex = new Map<number, string>();
for (const member of plan.matchedMembers) {
matchedPersonaIdsByImportedIndex.set(
createdPubkeysByImportedIndex.set(
member.importedIndex,
member.existing.id,
created.agent.pubkey,
);
}
const nextPersonaIds: string[] = [];
for (
let importedIndex = 0;
importedIndex < teamImportTargetPreview.preview.personas.length;
importedIndex += 1
) {
const matchedId = matchedPersonaIdsByImportedIndex.get(importedIndex);
if (matchedId) {
nextPersonaIds.push(matchedId);
continue;
const nextAgentPubkeys: string[] = [];
const pushMember = (kind: "agent" | "persona", id: string) => {
if (kind === "persona") {
nextPersonaIds.push(id);
} else {
nextAgentPubkeys.push(id);
}
};
const createdId = createdPersonaIdsByImportedIndex.get(importedIndex);
if (createdId) {
nextPersonaIds.push(createdId);
}
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) =>
removePersonaIdSet.has(member.existing.id),
removeMemberIdSet.has(member.existing.id),
);
const keptMissingMembers = plan.missingMembers.filter(
(member) => !removePersonaIdSet.has(member.existing.id),
(member) => !removeMemberIdSet.has(member.existing.id),
);
for (const member of keptMissingMembers) {
nextPersonaIds.push(member.existing.id);
pushMember(member.existing.kind, member.existing.id);
}
const nextTeamName = updateTeamInfo
@@ -418,15 +457,20 @@ export function useTeamActions(
name: nextTeamName,
description: nextTeamDescription,
personaIds: nextPersonaIds,
agentPubkeys: nextAgentPubkeys,
});
let deletedAgentsCount = 0;
const deleteFailures: string[] = [];
const addedMembersCount = createdPersonaIdsByImportedIndex.size;
const addedMembersCount = createdPubkeysByImportedIndex.size;
if (deleteRemovedAgents && removedMembers.length > 0) {
for (const member of removedMembers) {
try {
await deletePersona(member.existing.id);
if (member.existing.kind === "persona") {
await deletePersona(member.existing.id);
} else {
await deleteManagedAgent(member.existing.id);
}
deletedAgentsCount += 1;
} catch (error) {
const reason =
@@ -437,7 +481,7 @@ export function useTeamActions(
}
actions.setActionNoticeMessage(
`Updated "${nextTeamName}" from import. ${updatedMembersCount} member${updatedMembersCount === 1 ? "" : "s"} updated, ${addedMembersCount} added, ${removedMembers.length} removed from the team${deleteRemovedAgents ? `, and ${deletedAgentsCount} removed from My Agents` : ""}.`,
`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) {
@@ -467,14 +511,15 @@ export function useTeamActions(
function handleTeamImportComplete(
teamName: string,
teamDescription: string | null,
personaIds: string[],
agentPubkeys: string[],
) {
setTeamImportPreview(null);
void (async () => {
const teamInput = {
name: teamName,
description: teamDescription ?? undefined,
personaIds,
personaIds: [],
agentPubkeys,
};
// Try creating the team, retry once on failure.
@@ -482,9 +527,11 @@ export function useTeamActions(
try {
await createTeamApi(teamInput);
actions.setActionNoticeMessage(
`Imported team "${teamName}" with ${personaIds.length} persona${personaIds.length !== 1 ? "s" : ""}.`,
`Imported team "${teamName}" with ${agentPubkeys.length} agent${agentPubkeys.length !== 1 ? "s" : ""}.`,
);
void queryClient.invalidateQueries({ queryKey: personasQueryKey });
void queryClient.invalidateQueries({
queryKey: managedAgentsQueryKey,
});
void queryClient.invalidateQueries({ queryKey: teamsQueryKey });
return;
} catch {
@@ -492,20 +539,24 @@ export function useTeamActions(
}
}
// Both attempts failed — personas exist but team doesn't.
// Both attempts failed — agents exist but team doesn't.
actions.setActionErrorMessage(
`Imported ${personaIds.length} persona${personaIds.length !== 1 ? "s" : ""} but failed to create team "${teamName}". The personas are saved — create a team manually to group them.`,
`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: personasQueryKey });
void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey });
})();
}
async function handleDeleteRemovedPersonas(personaIds: string[]) {
for (const id of personaIds) {
async function handleDeleteRemovedMembers(members: RemovedTeamMemberRef[]) {
for (const member of members) {
try {
await deletePersona(id);
if (member.kind === "persona") {
await deletePersona(member.id);
} else {
await deleteManagedAgent(member.id);
}
} catch {
// Best-effort: persona may already be deleted or in use elsewhere.
// Best-effort: the member may already be deleted or in use elsewhere.
}
}
await Promise.all([
@@ -526,6 +577,7 @@ export function useTeamActions(
name: team.name,
description: team.description ?? "",
personaIds: [...team.personaIds],
agentPubkeys: [...team.agentPubkeys],
},
});
}
@@ -549,7 +601,7 @@ export function useTeamActions(
teamImportTargetPreview,
isApplyingTeamImportUpdate,
handleTeamSubmit,
handleDeleteRemovedPersonas,
handleDeleteRemovedMembers,
handleDeleteTeam,
handleTeamDeployed,
handleExportTeam,

View File

@@ -1,20 +1,23 @@
import { useQueryClient } from "@tanstack/react-query";
import {
attachManagedAgentToChannel,
createChannelManagedAgents,
type CreateChannelManagedAgentInput,
} from "@/features/agents/channelAgents";
import {
useAgentTemplatesQuery,
useAvailableAcpRuntimes,
useManagedAgentsQuery,
usePersonasQuery,
useTeamsQuery,
} from "@/features/agents/hooks";
import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas";
import { resolveTeamMembers } from "@/features/agents/lib/teamMembers";
import { useLastRuntime } from "@/features/agents/lib/useLastRuntime";
import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks";
import { setCanvas } from "@/shared/api/tauri";
import type { ChannelTemplate } from "@/shared/api/types";
import type { ChannelTemplate, ManagedAgent } from "@/shared/api/types";
/**
* TemplateBackend omits `config` — supply an empty object for provider backends.
@@ -32,6 +35,8 @@ export function useApplyTemplate() {
const acpRuntimesQuery = useAvailableAcpRuntimes();
const personasQuery = usePersonasQuery();
const teamsQuery = useTeamsQuery();
const managedAgentsQuery = useManagedAgentsQuery();
const agentTemplatesQuery = useAgentTemplatesQuery();
const { lastRuntimeId } = useLastRuntime();
async function applyCanvas(
@@ -63,12 +68,13 @@ export function useApplyTemplate() {
(t) => t.id === templateId,
);
if (!template) return;
const { personas: templatePersonas, teams: templateTeams } =
template.agents;
if (templatePersonas.length === 0 && templateTeams.length === 0) return;
const { personas: templateEntries, teams: templateTeams } = template.agents;
if (templateEntries.length === 0 && templateTeams.length === 0) return;
const allPersonas = personasQuery.data ?? [];
const allTeams = teamsQuery.data ?? [];
const allAgents = managedAgentsQuery.data ?? [];
const agentTemplates = agentTemplatesQuery.data ?? [];
const runtimes = acpRuntimesQuery.data ?? [];
if (runtimes.length === 0) return; // No runtimes — skip silently
@@ -77,42 +83,31 @@ export function useApplyTemplate() {
runtimes.find((p) => p.id === lastRuntimeId) ?? runtimes[0] ?? null;
if (!defaultProvider) return;
const seenPersonaIds = new Set<string>();
const seenRefs = new Set<string>();
const attachAgents: ManagedAgent[] = [];
const inputs: CreateChannelManagedAgentInput[] = [];
// Direct personas from template
for (const entry of templatePersonas) {
const persona = allPersonas.find((p) => p.id === entry.personaId);
if (!persona) continue;
if (seenPersonaIds.has(persona.id)) continue;
seenPersonaIds.add(persona.id);
const resolved = resolvePersonaRuntime(
entry.runtime ?? persona.runtime,
runtimes,
defaultProvider,
);
inputs.push({
runtime: resolved.runtime ?? defaultProvider,
name: persona.displayName,
personaId: persona.id,
systemPrompt: persona.systemPrompt,
avatarUrl: persona.avatarUrl ?? undefined,
model: entry.model ?? persona.model ?? undefined,
role: "bot",
backend: toManagedBackend(entry.backend),
});
}
// A template member ref (the legacy `personaId` field) can be an agent
// pubkey, a pack persona id, or a built-in agent-template id.
const resolveEntry = (
ref: string,
runtimeHint: string | null,
model: string | null,
backend: ChannelTemplate["agents"]["personas"][number]["backend"],
) => {
if (seenRefs.has(ref)) return;
seenRefs.add(ref);
// Team-expanded personas (skip dupes)
for (const teamEntry of templateTeams) {
const team = allTeams.find((t) => t.id === teamEntry.teamId);
if (!team) continue;
const { resolvedPersonas } = resolveTeamPersonas(team, allPersonas);
for (const persona of resolvedPersonas) {
if (seenPersonaIds.has(persona.id)) continue;
seenPersonaIds.add(persona.id);
const agent = allAgents.find((candidate) => candidate.pubkey === ref);
if (agent) {
attachAgents.push(agent);
return;
}
const persona = allPersonas.find((candidate) => candidate.id === ref);
if (persona) {
const resolved = resolvePersonaRuntime(
teamEntry.runtime ?? persona.runtime,
runtimeHint ?? persona.runtime,
runtimes,
defaultProvider,
);
@@ -122,17 +117,86 @@ export function useApplyTemplate() {
personaId: persona.id,
systemPrompt: persona.systemPrompt,
avatarUrl: persona.avatarUrl ?? undefined,
model: teamEntry.model ?? persona.model ?? undefined,
model: model ?? persona.model ?? undefined,
role: "bot",
backend: toManagedBackend(teamEntry.backend),
backend: toManagedBackend(backend),
});
return;
}
const agentTemplate = agentTemplates.find(
(candidate) => candidate.id === ref,
);
if (agentTemplate) {
const resolved = resolvePersonaRuntime(
runtimeHint ?? agentTemplate.runtime,
runtimes,
defaultProvider,
);
inputs.push({
runtime: resolved.runtime ?? defaultProvider,
name: agentTemplate.displayName,
systemPrompt: agentTemplate.systemPrompt,
avatarUrl: agentTemplate.avatarUrl ?? undefined,
model: model ?? agentTemplate.model ?? undefined,
role: "bot",
backend: toManagedBackend(backend),
});
}
};
for (const entry of templateEntries) {
resolveEntry(entry.personaId, entry.runtime, entry.model, entry.backend);
}
// Team-expanded members (skip dupes)
for (const teamEntry of templateTeams) {
const team = allTeams.find((t) => t.id === teamEntry.teamId);
if (!team) continue;
const resolution = resolveTeamMembers(team, allPersonas, allAgents);
for (const agent of resolution.resolvedAgents) {
resolveEntry(
agent.pubkey,
teamEntry.runtime,
teamEntry.model,
teamEntry.backend,
);
}
for (const persona of resolution.resolvedPersonas) {
resolveEntry(
persona.id,
teamEntry.runtime,
teamEntry.model,
teamEntry.backend,
);
}
}
if (inputs.length === 0) return;
if (inputs.length === 0 && attachAgents.length === 0) return;
try {
const result = await createChannelManagedAgents(channelId, inputs);
const result =
inputs.length > 0
? await createChannelManagedAgents(channelId, inputs)
: { successes: [], failures: [] };
for (const agent of attachAgents) {
try {
await attachManagedAgentToChannel(channelId, {
agent,
role: "bot",
});
} catch (error) {
result.failures.push({
kind: "generic",
name: agent.name,
personaId: null,
error:
error instanceof Error ? error.message : "Failed to add agent.",
});
}
}
if (result.failures.length > 0) {
const { toast } = await import("sonner");
toast.warning(

View File

@@ -1,7 +1,8 @@
import { Bot, Check } from "lucide-react";
import type { AgentPersona } from "@/shared/api/types";
import type { ManagedAgent } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { promptPreview } from "@/shared/lib/promptPreview";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import {
@@ -63,65 +64,38 @@ function SelectionChipButton({
);
}
function RuntimeBadge({
label,
isOverridden,
}: {
label: string;
isOverridden: boolean;
}) {
return (
<span
className={cn(
"inline-flex items-center rounded-full px-1.5 py-0.5 text-2xs font-medium leading-none",
isOverridden
? "bg-warning/15 text-warning"
: "bg-muted/60 text-muted-foreground",
)}
>
{label}
</span>
);
}
type AddChannelBotPersonasSectionProps = {
type AddChannelBotAgentsSectionProps = {
agents: ManagedAgent[];
canToggleSelections: boolean;
/** Map of personaId → effective runtime label for badge display */
effectiveRuntimes?: ReadonlyMap<
string,
{ label: string; isOverridden: boolean }
>;
inChannelPersonaIds?: ReadonlySet<string>;
inChannelPubkeys?: ReadonlySet<string>;
includeGeneric: boolean;
isLoading: boolean;
onToggleAgent: (pubkey: string) => void;
onToggleGeneric: () => void;
onTogglePersona: (personaId: string) => void;
personas: AgentPersona[];
selectedPersonaIds: readonly string[];
selectedAgentPubkeys: readonly string[];
/** Whether to show the "Generic" chip. Defaults to true. */
showGeneric?: boolean;
};
export function AddChannelBotPersonasSection({
export function AddChannelBotAgentsSection({
agents,
canToggleSelections,
effectiveRuntimes,
inChannelPersonaIds,
inChannelPubkeys,
includeGeneric,
isLoading,
onToggleAgent,
onToggleGeneric,
onTogglePersona,
personas,
selectedPersonaIds,
selectedAgentPubkeys,
showGeneric = true,
}: AddChannelBotPersonasSectionProps) {
}: AddChannelBotAgentsSectionProps) {
return (
<div className="space-y-3">
<div className="space-y-3">
<div>
<div className="text-sm font-medium">Personas</div>
<div className="text-sm font-medium">Agents</div>
<p className="text-xs text-muted-foreground">
Toggle as many as you want. Each selected persona is added as its
own agent. Hover a persona to preview its role.
Toggle as many as you want. Each selected agent joins the channel.
Hover an agent to preview its role.
</p>
</div>
@@ -133,7 +107,7 @@ export function AddChannelBotPersonasSection({
<div>
<SelectionChipButton
disabled={!canToggleSelections}
label="Generic"
label="New agent"
onClick={onToggleGeneric}
selected={includeGeneric}
>
@@ -143,34 +117,32 @@ export function AddChannelBotPersonasSection({
includeGeneric ? "text-primary" : "text-current",
)}
/>
Generic
New agent
</SelectionChipButton>
</div>
</TooltipTrigger>
<TooltipContent className="max-w-xs text-left">
Add one custom agent with a channel-specific name and prompt.
Add one new agent with a channel-specific name and
instructions.
</TooltipContent>
</Tooltip>
) : null}
{personas.map((persona) => {
const isSelected = selectedPersonaIds.includes(persona.id);
const isInChannel = inChannelPersonaIds?.has(persona.id) ?? false;
const runtimeBadge = effectiveRuntimes?.get(persona.id);
{agents.map((agent) => {
const isSelected = selectedAgentPubkeys.includes(agent.pubkey);
const isInChannel =
inChannelPubkeys?.has(normalizePubkey(agent.pubkey)) ?? false;
return (
<Tooltip key={persona.id}>
<Tooltip key={agent.pubkey}>
<TooltipTrigger asChild>
<div>
<SelectionChipButton
avatarUrl={persona.avatarUrl}
avatarUrl={agent.avatarUrl}
disabled={!canToggleSelections || isInChannel}
label={persona.displayName}
onClick={() => onTogglePersona(persona.id)}
label={agent.name}
onClick={() => onToggleAgent(agent.pubkey)}
selected={isSelected}
>
{persona.displayName}
{runtimeBadge ? (
<RuntimeBadge {...runtimeBadge} />
) : null}
{agent.name}
{isInChannel ? (
<span
className={cn(
@@ -191,21 +163,23 @@ export function AddChannelBotPersonasSection({
<div className="space-y-2">
<div className="flex items-center gap-2">
<ProfileAvatar
avatarUrl={persona.avatarUrl}
avatarUrl={agent.avatarUrl}
className="h-7 w-7 text-2xs bg-primary-foreground/20 text-primary-foreground"
iconClassName="h-4 w-4"
label={persona.displayName}
label={agent.name}
/>
<p className="font-medium">{persona.displayName}</p>
<p className="font-medium">{agent.name}</p>
</div>
{isInChannel ? (
<p className="text-2xs font-medium text-emerald-300">
✓ Already in this channel
</p>
) : null}
<p className="text-2xs text-primary-foreground">
{promptPreview(persona.systemPrompt)}
</p>
{agent.systemPrompt ? (
<p className="text-2xs text-primary-foreground">
{promptPreview(agent.systemPrompt)}
</p>
) : null}
</div>
</TooltipContent>
</Tooltip>
@@ -215,7 +189,7 @@ export function AddChannelBotPersonasSection({
</TooltipProvider>
{isLoading ? (
<p className="text-xs text-muted-foreground">Loading personas...</p>
<p className="text-xs text-muted-foreground">Loading agents...</p>
) : null}
</div>
</div>

View File

@@ -1,23 +1,33 @@
import { AlertTriangle, ChevronDown } from "lucide-react";
import * as React from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
useCreateChannelManagedAgentsMutation,
useManagedAgentsQuery,
usePersonasQuery,
useTeamsQuery,
type CreateChannelManagedAgentResult,
} from "@/features/agents/hooks";
import { useInChannelPersonaIds } from "@/features/channels/ui/useInChannelPersonaIds";
import {
attachManagedAgentToChannel,
createChannelManagedAgents,
type CreateChannelManagedAgentsResult,
} from "@/features/agents/channelAgents";
import { useInChannelAgentPubkeys } from "@/features/channels/ui/useInChannelAgentPubkeys";
import { AddChannelBotGenericSection } from "@/features/channels/ui/AddChannelBotGenericSection";
import { AddChannelBotPersonasSection } from "@/features/channels/ui/AddChannelBotPersonasSection";
import { AddChannelBotAgentsSection } from "@/features/channels/ui/AddChannelBotAgentsSection";
import { AddChannelBotReuseGuard } from "@/features/channels/ui/AddChannelBotReuseGuard";
import { useReusableAgentDetection } from "@/features/channels/ui/useReusableAgentDetection";
import { AddChannelBotTeamsSection } from "@/features/channels/ui/AddChannelBotTeamsSection";
import { probeBackendProvider } from "@/shared/api/tauri";
import {
AddChannelBotTeamsSection,
resolveTeamAgentPubkeys,
} from "@/features/channels/ui/AddChannelBotTeamsSection";
import { probeBackendProvider, updateManagedAgent } from "@/shared/api/tauri";
import type {
AcpRuntime,
BackendProviderCandidate,
BackendProviderProbeResult,
ManagedAgent,
ManagedAgentBackend,
RespondToMode,
} from "@/shared/api/types";
@@ -29,17 +39,12 @@ import {
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import {
coerceConfigValues,
ProviderConfigFields,
} from "@/features/agents/ui/ProviderConfigFields";
import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
import { useEffectiveRuntimes } from "@/features/channels/ui/useEffectiveRuntimes";
import { getActivePersonas } from "@/features/agents/lib/catalog";
import { getUsableTeams } from "@/features/agents/lib/teamPersonas";
import { useLastRuntime } from "@/features/agents/lib/useLastRuntime";
import { CreateAgentRespondToField } from "@/features/agents/ui/RespondToField";
@@ -55,8 +60,6 @@ type AddChannelBotDialogProps = {
onOpenChange: (open: boolean) => void;
};
const RUNTIME_NONE_SENTINEL = "__none__";
function defaultBotName(runtime: AcpRuntime | null) {
if (!runtime) {
return "";
@@ -107,23 +110,24 @@ export function AddChannelBotDialog({
const { setLastRuntime } = useLastRuntime();
const personasQuery = usePersonasQuery();
const teamsQuery = useTeamsQuery();
const inChannelPersonaIds = useInChannelPersonaIds(
const managedAgentsQuery = useManagedAgentsQuery();
const inChannelPubkeys = useInChannelAgentPubkeys(
channelId,
open && channelId !== null,
);
const createBotsMutation = useCreateChannelManagedAgentsMutation(channelId);
const personas = React.useMemo(
() => getActivePersonas(personasQuery.data ?? []),
[personasQuery.data],
);
const agents = managedAgentsQuery.data ?? [];
const personas = personasQuery.data ?? [];
const teams = React.useMemo(
() => getUsableTeams(teamsQuery.data ?? [], personas),
[personas, teamsQuery.data],
() =>
(teamsQuery.data ?? []).filter(
(team) => resolveTeamAgentPubkeys(team, personas, agents).length > 0,
),
[agents, personas, teamsQuery.data],
);
const [selectedRuntimeId, setSelectedRuntimeId] = React.useState("");
const [selectedPersonaIds, setSelectedPersonaIds] = React.useState<string[]>(
[],
);
const [selectedAgentPubkeys, setSelectedAgentPubkeys] = React.useState<
string[]
>([]);
const [includeGeneric, setIncludeGeneric] = React.useState(false);
const [customName, setCustomName] = React.useState("");
const [customPrompt, setCustomPrompt] = React.useState("");
@@ -151,38 +155,30 @@ export function AddChannelBotDialog({
React.useState<BackendProviderProbeResult | null>(null);
const [probeError, setProbeError] = React.useState<string | null>(null);
const selectedRuntime = React.useMemo(
const genericRuntime = React.useMemo(
() =>
selectedRuntimeId
? (providers.find((runtime) => runtime.id === selectedRuntimeId) ??
null)
: null,
(selectedRuntimeId
? providers.find((runtime) => runtime.id === selectedRuntimeId)
: null) ??
providers[0] ??
null,
[providers, selectedRuntimeId],
);
const isOverrideActive = selectedRuntime !== null;
const selectedPersonas = React.useMemo(
() => personas.filter((persona) => selectedPersonaIds.includes(persona.id)),
[personas, selectedPersonaIds],
const selectedAgents = React.useMemo(
() => agents.filter((agent) => selectedAgentPubkeys.includes(agent.pubkey)),
[agents, selectedAgentPubkeys],
);
const selectedCount = selectedPersonas.length + (includeGeneric ? 1 : 0);
const selectedCount = selectedAgents.length + (includeGeneric ? 1 : 0);
const reusableAgent = useReusableAgentDetection(
channelId,
open && channelId !== null,
selectedRuntime ?? providers[0] ?? null,
selectedPersonas,
genericRuntime,
[],
includeGeneric,
customPrompt,
);
const { runtimeWarnings, effectiveRuntimes } = useEffectiveRuntimes(
personas,
selectedPersonas,
providers,
selectedRuntime,
isOverrideActive,
);
const isProviderMode = runOn !== "local";
const selectedBackendProvider = React.useMemo(
() => resolvedBackendProviders.find((p) => p.id === runOn) ?? null,
@@ -197,19 +193,23 @@ export function AddChannelBotDialog({
);
}, [isProviderMode, probedProvider, providerConfig]);
const createBotsMutation = useAddChannelBotsMutation(channelId);
React.useEffect(() => {
if (!selectedRuntime || hasEditedCustomName) {
if (!genericRuntime || hasEditedCustomName) {
return;
}
setCustomName(defaultBotName(selectedRuntime));
}, [hasEditedCustomName, selectedRuntime]);
setCustomName(defaultBotName(genericRuntime));
}, [hasEditedCustomName, genericRuntime]);
React.useEffect(() => {
setSelectedPersonaIds((current) =>
current.filter((id) => personas.some((persona) => persona.id === id)),
setSelectedAgentPubkeys((current) =>
current.filter((pubkey) =>
agents.some((agent) => agent.pubkey === pubkey),
),
);
}, [personas]);
}, [agents]);
React.useEffect(() => {
if (!isProviderMode || !selectedBackendProvider) {
@@ -257,7 +257,7 @@ export function AddChannelBotDialog({
function reset() {
setSelectedRuntimeId("");
setSelectedPersonaIds([]);
setSelectedAgentPubkeys([]);
setIncludeGeneric(false);
setCustomName(providers[0] ? defaultBotName(providers[0]) : "");
setCustomPrompt("");
@@ -282,13 +282,15 @@ export function AddChannelBotDialog({
onOpenChange(next);
}
function handleToggleTeam(personaIds: string[]) {
setSelectedPersonaIds((current) => {
const allSelected = personaIds.every((id) => current.includes(id));
function handleToggleTeam(agentPubkeys: string[]) {
setSelectedAgentPubkeys((current) => {
const allSelected = agentPubkeys.every((pubkey) =>
current.includes(pubkey),
);
if (allSelected) {
return current.filter((id) => !personaIds.includes(id));
return current.filter((pubkey) => !agentPubkeys.includes(pubkey));
}
const merged = new Set([...current, ...personaIds]);
const merged = new Set([...current, ...agentPubkeys]);
return [...merged];
});
setSubmissionNotice(null);
@@ -305,7 +307,10 @@ export function AddChannelBotDialog({
}
async function handleSubmit() {
if (providers.length === 0 || selectedCount === 0) {
if (selectedCount === 0) {
return;
}
if (includeGeneric && !genericRuntime) {
return;
}
@@ -329,52 +334,26 @@ export function AddChannelBotDialog({
}
: {};
const inputs = [
...(includeGeneric
? [
{
runtime: selectedRuntime ?? providers[0],
name: customName,
systemPrompt: customPrompt,
role: "bot" as const,
backend,
forceNewInstance,
...respondToFields,
},
]
: []),
...selectedPersonas.map((persona) => {
const effectiveFallback = selectedRuntime ?? providers[0] ?? null;
const resolved = resolvePersonaRuntime(
persona.runtime,
providers,
effectiveFallback,
isOverrideActive,
);
return {
runtime: resolved.runtime ?? effectiveFallback ?? providers[0],
name: persona.displayName,
personaId: persona.id,
// A deliberate runtime-selector pick overrides the persona; a Case-3
// fallback (persona runtime not installed) is NOT a pin. `isOverridden`
// alone can't tell them apart, so thread the explicit user intent.
harnessOverride: isOverrideActive,
systemPrompt: persona.systemPrompt,
avatarUrl: persona.avatarUrl ?? undefined,
model: persona.model ?? undefined,
role: "bot" as const,
backend,
forceNewInstance,
...respondToFields,
};
}),
];
setSubmissionNotice(null);
setSubmissionError(null);
try {
const result = await createBotsMutation.mutateAsync(inputs);
const result = await createBotsMutation.mutateAsync({
agents: selectedAgents,
generic:
includeGeneric && genericRuntime
? {
runtime: genericRuntime,
name: customName,
systemPrompt: customPrompt,
backend,
forceNewInstance,
...respondToFields,
}
: null,
respondTo,
respondToAllowlist,
});
if (result.failures.length === 0) {
if (result.successes[0]) {
@@ -384,13 +363,14 @@ export function AddChannelBotDialog({
return;
}
const failedPersonaIds = new Set(
result.failures
.map((failure) => failure.personaId)
.filter((personaId): personaId is string => Boolean(personaId)),
const failedNames = new Set(
result.failures.map((failure) => failure.name),
);
setSelectedPersonaIds((current) =>
current.filter((personaId) => failedPersonaIds.has(personaId)),
setSelectedAgentPubkeys((current) =>
current.filter((pubkey) => {
const agent = agents.find((candidate) => candidate.pubkey === pubkey);
return agent ? failedNames.has(agent.name) : false;
}),
);
setIncludeGeneric(
result.failures.some((failure) => failure.kind === "generic"),
@@ -417,14 +397,14 @@ export function AddChannelBotDialog({
respondTo !== "allowlist" || respondToAllowlist.length > 0;
const canSubmit =
(selectedRuntime !== null || providers.length > 0) &&
selectedCount > 0 &&
(!includeGeneric || customName.trim().length > 0) &&
(!includeGeneric ||
(genericRuntime !== null && customName.trim().length > 0)) &&
respondToValid &&
!(isProviderMode && !probedProvider) &&
!(includeGeneric && isProviderMode && !probedProvider) &&
providerConfigComplete &&
!providersLoading &&
!(isProviderMode && resolvedBackendProvidersLoading) &&
!(includeGeneric && providersLoading) &&
!(includeGeneric && isProviderMode && resolvedBackendProvidersLoading) &&
!createBotsMutation.isPending;
const canChooseProvider =
providers.length > 0 && !providersLoading && !createBotsMutation.isPending;
@@ -433,7 +413,7 @@ export function AddChannelBotDialog({
? "Loading runtimes..."
: providers.length === 0
? "No runtimes found"
: (selectedRuntime?.label ?? "Use persona defaults");
: (genericRuntime?.label ?? "Choose runtime");
const addButtonLabel = createBotsMutation.isPending
? selectedCount > 1
? `Adding ${selectedCount}...`
@@ -447,7 +427,11 @@ export function AddChannelBotDialog({
<ChooserDialogContent
className="max-w-3xl"
data-testid="add-channel-bot-dialog"
description="Select any combination of saved personas, or turn on Generic for a one-off custom agent."
description="Select any combination of your agents, or turn on New agent for a one-off custom agent."
// Without this, open-autofocus lands on the first agent chip, whose
// focus-triggered tooltip becomes the topmost dismissable layer — the
// first Escape then closes the tooltip instead of the dialog.
onOpenAutoFocus={(event) => event.preventDefault()}
footer={
<>
<Button
@@ -475,157 +459,152 @@ export function AddChannelBotDialog({
scrollAreaTestId="add-channel-bot-dialog-scroll-area"
title="Add agents"
>
{resolvedBackendProviders.length > 0 ? (
<div className="space-y-1.5">
<div className="text-sm font-medium">Run on</div>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs"
disabled={createBotsMutation.isPending}
onChange={(e) => handleRunOnChange(e.target.value)}
value={runOn}
>
<option value="local">This computer</option>
{resolvedBackendProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.id}
</option>
))}
</select>
</div>
) : null}
{isProviderMode && selectedBackendProvider ? (
<div className="flex gap-3 rounded-2xl border border-warning/30 bg-warning-bg px-4 py-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p className="text-sm text-warning">
This provider at{" "}
<span className="font-mono font-medium">
{selectedBackendProvider.binaryPath}
</span>{" "}
will receive your agent&apos;s private key. Only use providers
from trusted sources.
</p>
</div>
) : null}
{probeError ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
Could not probe provider: {probeError}
</p>
) : null}
{isProviderMode && probedProvider?.config_schema ? (
<ProviderConfigFields
config={providerConfig}
onChange={setProviderConfig}
schema={probedProvider.config_schema}
/>
) : null}
<div className="space-y-1.5">
<div className="text-sm font-medium">Runtime</div>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
className="h-9 max-w-full justify-start gap-1.5 rounded-full border border-border/50 bg-muted/45 px-3 text-sm font-medium text-foreground shadow-none hover:bg-muted/70"
disabled={!canChooseProvider}
size="default"
type="button"
variant="ghost"
>
<span className="truncate">{runtimeTriggerLabel}</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-40"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuRadioGroup
onValueChange={(id) => {
if (id === RUNTIME_NONE_SENTINEL) {
setSelectedRuntimeId("");
} else {
setSelectedRuntimeId(id);
setLastRuntime(id);
}
}}
value={selectedRuntime?.id ?? RUNTIME_NONE_SENTINEL}
>
<DropdownMenuRadioItem value={RUNTIME_NONE_SENTINEL}>
Use persona defaults
</DropdownMenuRadioItem>
<DropdownMenuSeparator />
{providers.map((provider) => (
<DropdownMenuRadioItem key={provider.id} value={provider.id}>
{provider.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<p className="text-xs text-muted-foreground">
{isOverrideActive
? "All agents will use this runtime, overriding persona preferences."
: "Each persona uses its preferred runtime. Choose a runtime above to override all."}
</p>
</div>
{teams.length > 0 ? (
<AddChannelBotTeamsSection
agents={agents}
canToggleSelections={canToggleSelections}
inChannelPersonaIds={inChannelPersonaIds}
inChannelPubkeys={inChannelPubkeys}
isLoading={teamsQuery.isLoading}
onToggleTeam={handleToggleTeam}
personas={personas}
selectedPersonaIds={selectedPersonaIds}
selectedAgentPubkeys={selectedAgentPubkeys}
teams={teams}
/>
) : null}
<AddChannelBotPersonasSection
<AddChannelBotAgentsSection
agents={agents}
canToggleSelections={canToggleSelections}
effectiveRuntimes={effectiveRuntimes}
inChannelPersonaIds={inChannelPersonaIds}
inChannelPubkeys={inChannelPubkeys}
includeGeneric={includeGeneric}
isLoading={personasQuery.isLoading}
isLoading={managedAgentsQuery.isLoading}
onToggleGeneric={() => {
setIncludeGeneric((current) => !current);
setSubmissionNotice(null);
setSubmissionError(null);
}}
onTogglePersona={(personaId) => {
setSelectedPersonaIds((current) => toggleValue(current, personaId));
onToggleAgent={(pubkey) => {
setSelectedAgentPubkeys((current) => toggleValue(current, pubkey));
setSubmissionNotice(null);
setSubmissionError(null);
}}
personas={personas}
selectedPersonaIds={selectedPersonaIds}
selectedAgentPubkeys={selectedAgentPubkeys}
/>
{includeGeneric ? (
<AddChannelBotGenericSection
disabled={createBotsMutation.isPending}
name={customName}
onNameChange={(value) => {
setHasEditedCustomName(true);
setCustomName(value);
}}
onPromptChange={setCustomPrompt}
prompt={customPrompt}
/>
) : null}
<>
<div className="space-y-1.5">
<div className="text-sm font-medium">Runtime</div>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
className="h-9 max-w-full justify-start gap-1.5 rounded-full border border-border/50 bg-muted/45 px-3 text-sm font-medium text-foreground shadow-none hover:bg-muted/70"
disabled={!canChooseProvider}
size="default"
type="button"
variant="ghost"
>
<span className="truncate">{runtimeTriggerLabel}</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-40"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuRadioGroup
onValueChange={(id) => {
setSelectedRuntimeId(id);
setLastRuntime(id);
}}
value={genericRuntime?.id ?? ""}
>
{providers.map((provider) => (
<DropdownMenuRadioItem
key={provider.id}
value={provider.id}
>
{provider.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<p className="text-xs text-muted-foreground">
The new agent runs on this runtime.
</p>
</div>
{reusableAgent ? (
<div className="pt-2">
<AddChannelBotReuseGuard
{resolvedBackendProviders.length > 0 ? (
<div className="space-y-1.5">
<div className="text-sm font-medium">Run on</div>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs"
disabled={createBotsMutation.isPending}
onChange={(e) => handleRunOnChange(e.target.value)}
value={runOn}
>
<option value="local">This computer</option>
{resolvedBackendProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.id}
</option>
))}
</select>
</div>
) : null}
{isProviderMode && selectedBackendProvider ? (
<div className="flex gap-3 rounded-2xl border border-warning/30 bg-warning-bg px-4 py-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p className="text-sm text-warning">
This provider at{" "}
<span className="font-mono font-medium">
{selectedBackendProvider.binaryPath}
</span>{" "}
will receive your agent&apos;s private key. Only use providers
from trusted sources.
</p>
</div>
) : null}
{probeError ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
Could not probe provider: {probeError}
</p>
) : null}
{isProviderMode && probedProvider?.config_schema ? (
<ProviderConfigFields
config={providerConfig}
onChange={setProviderConfig}
schema={probedProvider.config_schema}
/>
) : null}
<AddChannelBotGenericSection
disabled={createBotsMutation.isPending}
forceNew={forceNewInstance}
onForceNewChange={setForceNewInstance}
reusableAgent={reusableAgent}
name={customName}
onNameChange={(value) => {
setHasEditedCustomName(true);
setCustomName(value);
}}
onPromptChange={setCustomPrompt}
prompt={customPrompt}
/>
</div>
{reusableAgent ? (
<div className="pt-2">
<AddChannelBotReuseGuard
disabled={createBotsMutation.isPending}
forceNew={forceNewInstance}
onForceNewChange={setForceNewInstance}
reusableAgent={reusableAgent}
/>
</div>
) : null}
</>
) : null}
{selectedCount > 0 ? (
@@ -640,7 +619,7 @@ export function AddChannelBotDialog({
{selectedCount === 0 ? (
<div className="rounded-2xl border border-dashed border-border/70 bg-muted/15 px-4 py-4 text-sm text-muted-foreground">
Pick one or more personas, or enable Generic to add a custom agent.
Pick one or more agents, or enable New agent to add a custom agent.
</div>
) : null}
@@ -650,21 +629,9 @@ export function AddChannelBotDialog({
</p>
) : null}
{runtimeWarnings.length > 0
? runtimeWarnings.map((warning) => (
<div
className="flex gap-3 rounded-2xl border border-warning/30 bg-warning-bg px-4 py-3"
key={warning}
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p className="text-sm text-warning">{warning}</p>
</div>
))
: null}
{personasQuery.error instanceof Error ? (
{managedAgentsQuery.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{personasQuery.error.message}
{managedAgentsQuery.error.message}
</p>
) : null}
@@ -689,3 +656,96 @@ export function AddChannelBotDialog({
</Dialog>
);
}
type AddChannelBotsInput = {
agents: ManagedAgent[];
generic: {
runtime: AcpRuntime;
name: string;
systemPrompt: string;
backend: ManagedAgentBackend;
forceNewInstance: boolean;
respondTo?: RespondToMode;
respondToAllowlist?: string[];
} | null;
respondTo: RespondToMode;
respondToAllowlist: string[];
};
function useAddChannelBotsMutation(channelId: string | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (
input: AddChannelBotsInput,
): Promise<CreateChannelManagedAgentsResult> => {
if (!channelId) {
throw new Error("No channel selected.");
}
const result: CreateChannelManagedAgentsResult = input.generic
? await createChannelManagedAgents(channelId, [
{
runtime: input.generic.runtime,
name: input.generic.name,
systemPrompt: input.generic.systemPrompt,
role: "bot" as const,
backend: input.generic.backend,
forceNewInstance: input.generic.forceNewInstance,
respondTo: input.generic.respondTo,
respondToAllowlist: input.generic.respondToAllowlist,
},
])
: { successes: [], failures: [] };
// Existing agents attach directly; permission changes apply first so
// the user's respond-to choice is honored on the attached agent.
for (const agent of input.agents) {
try {
let target = agent;
if (input.respondTo !== "owner-only") {
target = (
await updateManagedAgent({
pubkey: agent.pubkey,
respondTo: input.respondTo,
respondToAllowlist:
input.respondTo === "allowlist"
? input.respondToAllowlist
: undefined,
})
).agent;
}
const attached = await attachManagedAgentToChannel(channelId, {
agent: target,
role: "bot",
});
result.successes.push({
...attached,
created: false,
runtimeId: "",
});
} catch (error) {
result.failures.push({
kind: "generic",
name: agent.name,
personaId: null,
error:
error instanceof Error ? error.message : "Failed to add agent.",
});
}
}
return result;
},
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: ["managed-agents"] });
void queryClient.invalidateQueries({ queryKey: ["relay-agents"] });
void queryClient.invalidateQueries({ queryKey: ["channels"] });
if (channelId) {
void queryClient.invalidateQueries({
queryKey: ["channels", channelId, "members"],
});
}
},
});
}

View File

@@ -1,10 +1,11 @@
import { Check, Users } from "lucide-react";
import type * as React from "react";
import type { AgentPersona, AgentTeam } from "@/shared/api/types";
import type { AgentPersona, AgentTeam, ManagedAgent } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas";
import { resolveTeamMembers } from "@/features/agents/lib/teamMembers";
import {
Tooltip,
TooltipContent,
@@ -46,23 +47,45 @@ function SelectionChipButton({
);
}
/**
* Resolve a team's members to concrete agent pubkeys: direct agent members
* plus the agents backing any pack persona members.
*/
export function resolveTeamAgentPubkeys(
team: AgentTeam,
personas: AgentPersona[],
agents: ManagedAgent[],
): string[] {
const resolution = resolveTeamMembers(team, personas, agents);
const pubkeys = resolution.resolvedAgents.map((agent) => agent.pubkey);
for (const persona of resolution.resolvedPersonas) {
const backing = agents.find((agent) => agent.personaId === persona.id);
if (backing && !pubkeys.includes(backing.pubkey)) {
pubkeys.push(backing.pubkey);
}
}
return pubkeys;
}
type AddChannelBotTeamsSectionProps = {
agents: ManagedAgent[];
canToggleSelections: boolean;
inChannelPersonaIds?: ReadonlySet<string>;
inChannelPubkeys?: ReadonlySet<string>;
isLoading: boolean;
onToggleTeam: (personaIds: string[]) => void;
onToggleTeam: (agentPubkeys: string[]) => void;
personas: AgentPersona[];
selectedPersonaIds: readonly string[];
selectedAgentPubkeys: readonly string[];
teams: AgentTeam[];
};
export function AddChannelBotTeamsSection({
agents,
canToggleSelections,
inChannelPersonaIds,
inChannelPubkeys,
isLoading,
onToggleTeam,
personas,
selectedPersonaIds,
selectedAgentPubkeys,
teams,
}: AddChannelBotTeamsSectionProps) {
if (isLoading || teams.length === 0) {
@@ -74,23 +97,33 @@ export function AddChannelBotTeamsSection({
<div>
<div className="text-sm font-medium">Teams</div>
<p className="text-xs text-muted-foreground">
Select a team to toggle all its personas at once.
Select a team to toggle all its agents at once.
</p>
</div>
<TooltipProvider delayDuration={150}>
<div className="flex flex-wrap gap-2">
{teams.map((team) => {
const resolution = resolveTeamPersonas(team, personas);
const validIds = resolution.resolvedPersonaIds;
const memberPubkeys = resolveTeamAgentPubkeys(
team,
personas,
agents,
);
const memberAgents = memberPubkeys
.map((pubkey) => agents.find((agent) => agent.pubkey === pubkey))
.filter((agent): agent is ManagedAgent => agent != null);
const allSelected =
validIds.length > 0 &&
validIds.every((id) => selectedPersonaIds.includes(id));
const inChannelCount = inChannelPersonaIds
? validIds.filter((id) => inChannelPersonaIds.has(id)).length
memberPubkeys.length > 0 &&
memberPubkeys.every((pubkey) =>
selectedAgentPubkeys.includes(pubkey),
);
const inChannelCount = inChannelPubkeys
? memberPubkeys.filter((pubkey) =>
inChannelPubkeys.has(normalizePubkey(pubkey)),
).length
: 0;
const allInChannel =
inChannelCount > 0 && inChannelCount === validIds.length;
inChannelCount > 0 && inChannelCount === memberPubkeys.length;
return (
<Tooltip key={team.id}>
@@ -99,11 +132,11 @@ export function AddChannelBotTeamsSection({
<SelectionChipButton
disabled={
!canToggleSelections ||
!resolution.isUsable ||
memberPubkeys.length === 0 ||
allInChannel
}
label={team.name}
onClick={() => onToggleTeam(validIds)}
onClick={() => onToggleTeam(memberPubkeys)}
selected={allSelected}
>
<Users
@@ -119,7 +152,7 @@ export function AddChannelBotTeamsSection({
allSelected ? "text-primary/70" : "text-current/70",
)}
>
({validIds.length})
({memberPubkeys.length})
</span>
{inChannelCount > 0 ? (
<span
@@ -148,23 +181,25 @@ export function AddChannelBotTeamsSection({
</p>
) : null}
<div className="flex flex-wrap gap-1">
{resolution.resolvedPersonas.map((persona) => {
const personaInChannel =
inChannelPersonaIds?.has(persona.id) ?? false;
{memberAgents.map((agent) => {
const agentInChannel =
inChannelPubkeys?.has(
normalizePubkey(agent.pubkey),
) ?? false;
return (
<div
className="flex items-center gap-1 rounded-full bg-primary-foreground/10 px-1.5 py-0.5"
key={persona.id}
key={agent.pubkey}
>
<ProfileAvatar
avatarUrl={persona.avatarUrl}
avatarUrl={agent.avatarUrl}
className="h-4 w-4 text-3xs bg-primary-foreground/20 text-primary-foreground"
label={persona.displayName}
label={agent.name}
/>
<span className="text-2xs text-primary-foreground">
{persona.displayName}
{agent.name}
</span>
{personaInChannel ? (
{agentInChannel ? (
<Check className="h-4 w-4 text-emerald-300" />
) : null}
</div>

View File

@@ -1,98 +0,0 @@
import * as React from "react";
import { Spinner } from "@/shared/ui/spinner";
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-lg",
"border border-border/50 shadow-xs",
"transition-transform duration-150 hover:scale-110",
"focus-visible:outline-hidden 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-lg object-cover"
referrerPolicy="no-referrer"
src={rewriteRelayUrl(persona.avatarUrl)}
/>
) : (
<span className="text-2xs font-semibold text-primary">
{initials}
</span>
)}
{isThisPending ? (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background/70">
<Spinner className="h-4 w-4 border-2 text-primary" />
</div>
) : null}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Add {instanceName} ({persona.displayName})
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
);
}

View File

@@ -1,51 +0,0 @@
import * as React from "react";
import {
collectRuntimeWarnings,
resolvePersonaRuntime,
} from "@/features/agents/lib/resolvePersonaRuntime";
import type { AcpRuntime, AgentPersona } from "@/shared/api/types";
type EffectiveRuntimeInfo = { label: string; isOverridden: boolean };
export function useEffectiveRuntimes(
personas: readonly AgentPersona[],
selectedPersonas: readonly AgentPersona[],
providers: readonly AcpRuntime[],
selectedRuntime: AcpRuntime | null,
isOverrideActive: boolean,
) {
const fallback = selectedRuntime ?? providers[0] ?? null;
const runtimeWarnings = React.useMemo(
() =>
collectRuntimeWarnings(
selectedPersonas,
providers,
fallback,
isOverrideActive,
),
[selectedPersonas, providers, fallback, isOverrideActive],
);
const effectiveRuntimes = React.useMemo(() => {
const map = new Map<string, EffectiveRuntimeInfo>();
for (const persona of personas) {
const resolved = resolvePersonaRuntime(
persona.runtime,
providers,
fallback,
isOverrideActive,
);
if (resolved.runtime) {
map.set(persona.id, {
label: resolved.runtime.label,
isOverridden: resolved.isOverridden,
});
}
}
return map;
}, [personas, providers, fallback, isOverrideActive]);
return { runtimeWarnings, effectiveRuntimes };
}

View File

@@ -0,0 +1,25 @@
import * as React from "react";
import { useChannelMembersQuery } from "@/features/channels/hooks";
import { normalizePubkey } from "@/shared/lib/pubkey";
/**
* Returns a `Set<string>` of normalized pubkeys that are already members of
* the given channel. The query is only enabled when `enabled` is true (e.g.
* when the dialog is open).
*/
export function useInChannelAgentPubkeys(
channelId: string | null,
enabled: boolean,
): ReadonlySet<string> {
const membersQuery = useChannelMembersQuery(channelId, enabled);
return React.useMemo(() => {
const members = membersQuery.data;
if (!members) {
return new Set<string>();
}
return new Set(members.map((member) => normalizePubkey(member.pubkey)));
}, [membersQuery.data]);
}

View File

@@ -1,38 +0,0 @@
import * as React from "react";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
import { useChannelMembersQuery } from "@/features/channels/hooks";
import { normalizePubkey } from "@/shared/lib/pubkey";
/**
* Returns a `Set<string>` of persona IDs whose managed agents are already
* members of the given channel. The query is only enabled when `enabled` is
* true (e.g. when the dialog is open).
*/
export function useInChannelPersonaIds(
channelId: string | null,
enabled: boolean,
): ReadonlySet<string> {
const membersQuery = useChannelMembersQuery(channelId, enabled);
const managedAgentsQuery = useManagedAgentsQuery();
return React.useMemo(() => {
const members = membersQuery.data;
const managedAgents = managedAgentsQuery.data;
if (!members || !managedAgents) {
return new Set<string>();
}
const memberPubkeys = new Set(
members.map((m) => normalizePubkey(m.pubkey)),
);
const ids = new Set<string>();
for (const agent of managedAgents) {
if (agent.personaId && memberPubkeys.has(normalizePubkey(agent.pubkey))) {
ids.add(agent.personaId);
}
}
return ids;
}, [membersQuery.data, managedAgentsQuery.data]);
}

View File

@@ -1,71 +0,0 @@
import * as React from "react";
import {
useAvailableAcpRuntimes,
useCreateChannelManagedAgentMutation,
} from "@/features/agents/hooks";
import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
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 = useAvailableAcpRuntimes();
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 { runtime } = resolvePersonaRuntime(
persona.runtime,
providers,
defaultProvider,
);
if (!runtime) {
setState({
pending: false,
error: "No agent runtime available.",
});
return;
}
await createMutation.mutateAsync({
runtime,
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 };
}

View File

@@ -6,7 +6,6 @@ import {
pickWelcomeGuideAgent,
pickWelcomeGuideAgentForRelay,
WELCOME_GUIDE_AGENT_NAME,
WELCOME_GUIDE_PERSONA_ID,
} from "./welcomeGuide.ts";
const PUB_A = "a".repeat(64);
@@ -61,10 +60,9 @@ test("pickWelcomeGuideAgent reuses a legacy Kit guide", () => {
assert.equal(pickWelcomeGuideAgent([legacyKit]), legacyKit);
});
test("pickWelcomeGuideAgent prefers a running legacy guide over stopped builtin Fizz", () => {
test("pickWelcomeGuideAgent prefers a running legacy guide over stopped Fizz", () => {
const stoppedBuiltinFizz = makeAgent({
pubkey: PUB_A,
personaId: WELCOME_GUIDE_PERSONA_ID,
status: "stopped",
});
const runningLegacyKit = makeAgent({
@@ -88,7 +86,6 @@ test("pickWelcomeGuideAgent ignores non-Kit agents with the legacy prompt", () =
});
const fizz = makeAgent({
pubkey: PUB_C,
personaId: WELCOME_GUIDE_PERSONA_ID,
});
assert.equal(pickWelcomeGuideAgent([nonKit, fizz]), fizz);
@@ -97,13 +94,11 @@ test("pickWelcomeGuideAgent ignores non-Kit agents with the legacy prompt", () =
test("pickWelcomeGuideAgentForRelay ignores Fizz agents from other workspaces", () => {
const otherWorkspaceFizz = makeAgent({
pubkey: PUB_A,
personaId: WELCOME_GUIDE_PERSONA_ID,
relayUrl: RELAY_A,
status: "running",
});
const currentWorkspaceFizz = makeAgent({
pubkey: PUB_B,
personaId: WELCOME_GUIDE_PERSONA_ID,
relayUrl: RELAY_B,
status: "stopped",
});
@@ -120,7 +115,6 @@ test("pickWelcomeGuideAgentForRelay ignores Fizz agents from other workspaces",
test("pickWelcomeGuideAgentForRelay returns null when Fizz only exists in another workspace", () => {
const otherWorkspaceFizz = makeAgent({
pubkey: PUB_A,
personaId: WELCOME_GUIDE_PERSONA_ID,
relayUrl: RELAY_A,
});

View File

@@ -1,16 +1,17 @@
import {
addChannelMembers,
createManagedAgent,
discoverAcpRuntimes,
getChannelMembers,
listManagedAgents,
} from "@/shared/api/tauri";
import { sendManagedAgentChannelMessage } from "@/shared/api/tauriManagedAgentMessages";
import { listPersonas, setPersonaActive } from "@/shared/api/tauriPersonas";
import type { ManagedAgent } from "@/shared/api/types";
import { listAgentTemplates } from "@/shared/api/tauriManagedAgents";
import type { AcpRuntime, ManagedAgent } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
export const WELCOME_GUIDE_AGENT_NAME = "Fizz";
export const WELCOME_GUIDE_PERSONA_ID = "builtin:fizz";
export const WELCOME_GUIDE_TEMPLATE_ID = "builtin:fizz";
export const WELCOME_GUIDE_INTRO_MARKER = "buzz-welcome-intro.v1";
const LEGACY_WELCOME_GUIDE_AGENT_NAME = "Kit";
export const LEGACY_WELCOME_GUIDE_SYSTEM_PROMPT =
@@ -36,13 +37,6 @@ function isNamedWelcomeGuideAgent(agent: ManagedAgent) {
);
}
function isBuiltInWelcomeGuideAgent(agent: ManagedAgent) {
return (
agent.personaId === WELCOME_GUIDE_PERSONA_ID &&
isNamedWelcomeGuideAgent(agent)
);
}
function isLegacyKitWelcomeGuideAgent(agent: ManagedAgent) {
return (
agent.name.trim().toLowerCase() ===
@@ -52,9 +46,7 @@ function isLegacyKitWelcomeGuideAgent(agent: ManagedAgent) {
}
function isWelcomeGuideAgent(agent: ManagedAgent) {
return (
isBuiltInWelcomeGuideAgent(agent) || isLegacyKitWelcomeGuideAgent(agent)
);
return isNamedWelcomeGuideAgent(agent) || isLegacyKitWelcomeGuideAgent(agent);
}
function pickAgentByStatus(agents: ManagedAgent[]) {
@@ -91,16 +83,18 @@ export async function getWelcomeGuideAgentPubkeys(relayUrl?: string | null) {
.map((agent) => agent.pubkey);
}
async function ensureWelcomeGuidePersonaActive() {
const guidePersona = (await listPersonas()).find(
(persona) => persona.id === WELCOME_GUIDE_PERSONA_ID,
async function resolveWelcomeGuideRuntime(
preferredRuntimeId: string | null,
): Promise<AcpRuntime | null> {
const runtimes = (await discoverAcpRuntimes()).filter(
(runtime): runtime is AcpRuntime => runtime.availability === "available",
);
if (!guidePersona) {
throw new Error(`${WELCOME_GUIDE_AGENT_NAME} persona not found.`);
}
if (!guidePersona.isActive) {
await setPersonaActive(WELCOME_GUIDE_PERSONA_ID, true);
if (runtimes.length === 0) {
return null;
}
return (
runtimes.find((runtime) => runtime.id === preferredRuntimeId) ?? runtimes[0]
);
}
async function ensureWelcomeGuideAgent(relayUrl?: string | null) {
@@ -110,12 +104,26 @@ async function ensureWelcomeGuideAgent(relayUrl?: string | null) {
return existing;
}
await ensureWelcomeGuidePersonaActive();
// Create Fizz from the built-in agent template — no persona record.
const template = (await listAgentTemplates()).find(
(candidate) => candidate.id === WELCOME_GUIDE_TEMPLATE_ID,
);
if (!template) {
throw new Error(`${WELCOME_GUIDE_AGENT_NAME} template not found.`);
}
const runtime = await resolveWelcomeGuideRuntime(template.runtime);
const created = await createManagedAgent({
name: WELCOME_GUIDE_AGENT_NAME,
personaId: WELCOME_GUIDE_PERSONA_ID,
relayUrl: relayUrl ?? undefined,
acpCommand: "buzz-acp",
agentCommand: runtime?.command,
agentArgs: runtime?.defaultArgs,
mcpCommand: runtime?.mcpCommand ?? undefined,
systemPrompt: template.systemPrompt,
avatarUrl: template.avatarUrl ?? undefined,
model: template.model ?? undefined,
spawnAfterCreate: false,
startOnAppLaunch: false,
respondTo: "owner-only",

View File

@@ -38,31 +38,28 @@ export function UserProfileAgentSettingsMenu({
isBot = false,
managedAgent,
onDelete,
onDuplicatePersona,
onExportPersona,
onDuplicateAgent,
onExportAgent,
onToggleAutoStart,
personaActionKey,
}: {
archiveActions?: IdentityArchiveActions;
isPending: boolean;
isBot?: boolean;
managedAgent?: ManagedAgent;
onDelete?: () => void;
onDuplicatePersona?: () => void;
onExportPersona?: () => void;
onDuplicateAgent?: () => void;
onExportAgent?: () => void;
onToggleAutoStart?: () => void;
personaActionKey?: string;
}) {
const [archiveConfirmOpen, setArchiveConfirmOpen] = React.useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = React.useState(false);
const actionKey = managedAgent?.pubkey ?? "persona-draft";
const personaKey = personaActionKey ?? actionKey;
const actionKey = managedAgent?.pubkey ?? "agent-draft";
const canToggleAutoStart =
managedAgent !== undefined &&
managedAgent.backend.type === "local" &&
onToggleAutoStart !== undefined;
const autoStartSwitchId = `user-profile-agent-auto-start-${actionKey}`;
const hasPrimaryActions = Boolean(onDuplicatePersona || onExportPersona);
const hasPrimaryActions = Boolean(onDuplicateAgent || onExportAgent);
const hasArchiveAction =
archiveActions?.canArchive === true &&
archiveActions.isArchived !== undefined;
@@ -122,21 +119,21 @@ export function UserProfileAgentSettingsMenu({
/>
</DropdownMenuItem>
) : null}
{onDuplicatePersona ? (
{onDuplicateAgent ? (
<DropdownMenuItem
data-testid={`user-profile-persona-duplicate-${personaKey}`}
data-testid={`user-profile-agent-duplicate-${actionKey}`}
disabled={isPending}
onClick={onDuplicatePersona}
onClick={onDuplicateAgent}
>
<CopyPlus className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
) : null}
{onExportPersona ? (
{onExportAgent ? (
<DropdownMenuItem
data-testid={`user-profile-persona-export-${personaKey}`}
data-testid={`user-profile-agent-export-${actionKey}`}
disabled={isPending}
onClick={onExportPersona}
onClick={onExportAgent}
>
<Download className="h-4 w-4" />
Export
@@ -216,33 +213,25 @@ export function UserProfileAgentSettingsMenu({
export function UserProfileAgentSettingsMenuSlot({
archiveActions,
canDeletePersona,
canInstantiateAgent,
canManagePersona,
canManageAgent,
isAgentActionPending,
isBot,
managedAgent,
onDeleteAgent,
onDeletePersona,
onDuplicatePersona,
onExportPersona,
onDuplicateAgent,
onExportAgent,
onToggleAutoStart,
personaActionKey,
viewerIsOwner,
}: {
archiveActions: IdentityArchiveActions;
canDeletePersona: boolean;
canInstantiateAgent: boolean;
canManagePersona: boolean;
canManageAgent: boolean;
isAgentActionPending: boolean;
isBot: boolean;
managedAgent?: ManagedAgent;
onDeleteAgent: () => void;
onDeletePersona: () => void;
onDuplicatePersona: () => void;
onExportPersona: () => void;
onDuplicateAgent: () => void;
onExportAgent: () => void;
onToggleAutoStart: () => void;
personaActionKey?: string;
viewerIsOwner: boolean;
}) {
const canShowArchiveAction =
@@ -253,9 +242,8 @@ export function UserProfileAgentSettingsMenuSlot({
archiveActions: canShowArchiveAction ? archiveActions : undefined,
isBot,
isPending: settingsActionPending,
onDuplicatePersona: canManagePersona ? onDuplicatePersona : undefined,
onExportPersona: canManagePersona ? onExportPersona : undefined,
personaActionKey,
onDuplicateAgent: canManageAgent ? onDuplicateAgent : undefined,
onExportAgent: canManageAgent ? onExportAgent : undefined,
};
if (viewerIsOwner && managedAgent) {
@@ -269,15 +257,6 @@ export function UserProfileAgentSettingsMenuSlot({
);
}
if (canInstantiateAgent) {
return (
<UserProfileAgentSettingsMenu
{...sharedProps}
onDelete={canDeletePersona ? onDeletePersona : undefined}
/>
);
}
if (canShowArchiveAction) {
return (
<UserProfileAgentSettingsMenu

View File

@@ -8,27 +8,20 @@ import {
} from "@/features/agent-memory/hooks";
import {
type AttachManagedAgentToChannelResult,
useAcpRuntimesQuery,
useAvailableAcpRuntimes,
useCreateManagedAgentMutation,
useCreatePersonaMutation,
useDeleteManagedAgentMutation,
useDeletePersonaMutation,
useExportPersonaJsonMutation,
useExportAgentJsonMutation,
useManagedAgentLogQuery,
useRelayAgentsQuery,
useManagedAgentsQuery,
usePersonasQuery,
useSetManagedAgentStartOnAppLaunchMutation,
useSetPersonaActiveMutation,
useStartManagedAgentMutation,
useStopManagedAgentMutation,
useUpdateManagedAgentMutation,
useUpdatePersonaMutation,
} from "@/features/agents/hooks";
import { AddAgentToChannelDialog } from "@/features/agents/ui/AddAgentToChannelDialog";
import { useActiveAgentTurnsBridge } from "@/features/agents/activeAgentTurnsStore";
import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
import { duplicateAgentDraft } from "@/features/agents/ui/agentDraft";
import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent";
import {
isManagedAgentActive,
startManagedAgentWithRules,
@@ -37,11 +30,6 @@ import {
import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore";
import { describeLogFile } from "@/features/agents/ui/agentUi";
import { EditAgentDialog } from "@/features/agents/ui/EditAgentDialog";
import {
duplicatePersonaDialogState,
editPersonaDialogState,
type PersonaDialogState,
} from "@/features/agents/ui/personaDialogState";
import { useChannelsQuery } from "@/features/channels/hooks";
import { useIdentityArchive } from "@/features/identity-archive/hooks";
import { usePresenceQuery } from "@/features/presence/hooks";
@@ -65,8 +53,6 @@ import { AgentConfigurationFocusedView } from "@/features/profile/ui/UserProfile
import { UserProfileAgentSettingsMenuSlot } from "@/features/profile/ui/UserProfileAgentActions";
import { useProfileAgentDeletion } from "@/features/profile/ui/UserProfilePanelDeletion";
import { useProfileFieldBuckets } from "@/features/profile/ui/UserProfilePanelFields";
import { submitProfilePersonaDialog } from "@/features/profile/ui/UserProfilePanelPersonaSubmit";
import { UserProfilePersonaDialogs } from "@/features/profile/ui/UserProfilePersonaDialogs";
import {
deriveProfileChannels,
type ProfilePanelTab,
@@ -85,14 +71,7 @@ import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanel";
import { cn } from "@/shared/lib/cn";
import type {
AgentPersona,
Channel,
CreateManagedAgentInput,
CreatePersonaInput,
ManagedAgent,
UpdatePersonaInput,
} from "@/shared/api/types";
import type { Channel, ManagedAgent } from "@/shared/api/types";
import { UserProfilePanelFrame } from "@/features/profile/ui/UserProfilePanelFrame";
import { getUserProfilePanelHeaderContent } from "@/features/profile/ui/UserProfilePanelHeaderContent";
export type { ProfilePanelTab, ProfilePanelView };
@@ -109,7 +88,6 @@ export function UserProfilePanel({
onResizeStart,
onTabChange,
onViewChange,
persona,
pubkey,
splitPaneClamp = false,
tab: controlledTab,
@@ -148,10 +126,6 @@ export function UserProfilePanel({
);
const [editAgentOpen, setEditAgentOpen] = React.useState(false);
const [addToChannelOpen, setAddToChannelOpen] = React.useState(false);
const [personaDialogState, setPersonaDialogState] =
React.useState<PersonaDialogState | null>(null);
const [personaToDelete, setPersonaToDelete] =
React.useState<AgentPersona | null>(null);
const personasQuery = usePersonasQuery();
const managedAgentsQuery = useManagedAgentsQuery({ enabled: true });
@@ -161,33 +135,17 @@ export function UserProfilePanel({
const pubkeyLower = pubkey.toLowerCase();
return agents.find((agent) => agent.pubkey.toLowerCase() === pubkeyLower);
}
if (persona) {
return agents.find((agent) => agent.personaId === persona.id);
}
return undefined;
}, [managedAgentsQuery.data, persona, pubkey]);
}, [managedAgentsQuery.data, pubkey]);
const resolvedPersonaFromSource = React.useMemo(() => {
const personaId = persona?.id ?? managedAgent?.personaId;
if (personaId) {
const refreshedPersona = personasQuery.data?.find(
(candidate) => candidate.id === personaId,
);
if (refreshedPersona) {
return refreshedPersona;
}
}
if (persona) {
return persona;
}
if (!managedAgent?.personaId) {
return undefined;
}
return personasQuery.data?.find(
(candidate) => candidate.id === managedAgent.personaId,
);
}, [managedAgent?.personaId, persona, personasQuery.data]);
const profileIdentityKey =
pubkey ?? managedAgent?.pubkey ?? `persona:${persona?.id ?? "unknown"}`;
}, [managedAgent?.personaId, personasQuery.data]);
const profileIdentityKey = pubkey ?? managedAgent?.pubkey ?? "unknown";
const resolvedPersona = useRetainedPersona(
resolvedPersonaFromSource,
profileIdentityKey,
@@ -204,19 +162,11 @@ export function UserProfilePanel({
}, [effectivePubkey, profileQuery.refetch]);
const relayAgentsQuery = useRelayAgentsQuery({ enabled: true });
const availableRuntimesQuery = useAvailableAcpRuntimes();
const acpRuntimesQuery = useAcpRuntimesQuery();
const createAgentMutation = useCreateManagedAgentMutation();
const updateManagedAgentMutation = useUpdateManagedAgentMutation();
const startAgentMutation = useStartManagedAgentMutation();
const stopAgentMutation = useStopManagedAgentMutation();
const deleteAgentMutation = useDeleteManagedAgentMutation();
const startOnLaunchMutation = useSetManagedAgentStartOnAppLaunchMutation();
const createPersonaMutation = useCreatePersonaMutation();
const updatePersonaMutation = useUpdatePersonaMutation();
const deletePersonaMutation = useDeletePersonaMutation();
const setPersonaActiveMutation = useSetPersonaActiveMutation();
const exportPersonaJsonMutation = useExportPersonaJsonMutation();
const exportAgentJsonMutation = useExportAgentJsonMutation();
const usersBatchQuery = useUsersBatchQuery(
effectivePubkey ? [effectivePubkey] : [],
);
@@ -231,7 +181,7 @@ export function UserProfilePanel({
const followMutation = useFollowMutation(currentPubkey);
const unfollowMutation = useUnfollowMutation(currentPubkey);
const { onOpenAgentSession } = useAgentSession();
const { goChannel } = useAppNavigation();
const { goAgents, goChannel } = useAppNavigation();
const profile = resolvePanelProfile({
managedAgent,
persona: resolvedPersona,
@@ -261,9 +211,9 @@ export function UserProfilePanel({
const isBot =
Boolean(relayAgent || managedAgent || resolvedPersona) || isAgentByOaOwner;
const managedAgentOwner = useIsManagedAgent(isBot ? effectivePubkey : null);
// Does THIS desktop hold the agent's seckey (or is this an editable persona)?
// Gates edit (which needs the key) and grants owner access when managed locally.
const isOwner = resolvedPersona ? true : managedAgentOwner;
// Does THIS desktop hold the agent's seckey? Gates edit (which needs the
// key) and grants owner access when managed locally.
const isOwner = managedAgentOwner;
// Is the viewer the agent's declared owner (NIP-OA `ownerPubkey == me`)? This
// is the right signal for viewing owner-scoped data (activity feed, memory):
// the relay routes and the client decrypts those frames with the owner's OWN
@@ -310,10 +260,7 @@ export function UserProfilePanel({
}, [managedAgent, relayAgent, viewerIsOwner]);
useActiveAgentTurnsBridge(bridgeAgents);
useManagedAgentObserverBridge(observerBridgeAgents);
const canEditAgent =
isOwner === true &&
(managedAgent !== undefined ||
(resolvedPersona !== undefined && !resolvedPersona.isBuiltIn));
const canEditAgent = isOwner === true && managedAgent !== undefined;
const memoryQuery = useAgentMemoryQuery(effectivePubkey, {
enabled: viewerIsOwner && Boolean(effectivePubkey),
});
@@ -325,22 +272,12 @@ export function UserProfilePanel({
viewerIsOwner && Boolean(onOpenAgentSession) && Boolean(effectivePubkey);
const canOpenAgentLogs =
isOwner === true && managedAgent?.backend.type === "local";
const canInstantiateAgent =
isOwner === true &&
resolvedPersona !== undefined &&
managedAgent === undefined;
const isAgentActionPending =
createAgentMutation.isPending ||
updateManagedAgentMutation.isPending ||
startAgentMutation.isPending ||
stopAgentMutation.isPending ||
deleteAgentMutation.isPending ||
startOnLaunchMutation.isPending ||
createPersonaMutation.isPending ||
updatePersonaMutation.isPending ||
deletePersonaMutation.isPending ||
setPersonaActiveMutation.isPending ||
exportPersonaJsonMutation.isPending;
exportAgentJsonMutation.isPending;
const isFollowing =
!isSelf &&
pubkeyLower.length > 0 &&
@@ -368,8 +305,7 @@ export function UserProfilePanel({
return map;
}, [channelsQuery.data]);
const targetKey =
effectivePubkey ?? `persona:${resolvedPersona?.id ?? "unknown"}`;
const targetKey = effectivePubkey ?? "unknown";
const prevTargetKeyRef = React.useRef(targetKey);
React.useEffect(() => {
if (prevTargetKeyRef.current === targetKey) return;
@@ -384,69 +320,15 @@ export function UserProfilePanel({
});
const handleEditAgent = React.useCallback(() => {
if (resolvedPersona && !resolvedPersona.isBuiltIn) {
setPersonaDialogState(editPersonaDialogState(resolvedPersona));
return;
}
setEditAgentOpen(true);
}, [resolvedPersona]);
}, []);
const { deleteManagedAgentRecord, deleteManagedAgentsForPersona } =
useProfileAgentDeletion({
channels: channelsQuery.data,
deleteManagedAgent: deleteAgentMutation.mutateAsync,
managedAgent,
managedAgents: managedAgentsQuery.data,
presenceLookup: presenceQuery.data,
relayAgents: relayAgentsQuery.data,
});
const createManagedAgentForPersona = React.useCallback(
async (personaToStart: AgentPersona) => {
const runtimes = availableRuntimesQuery.data ?? [];
const defaultRuntime = runtimes[0] ?? null;
const { runtime, warnings } = resolvePersonaRuntime(
personaToStart.runtime,
runtimes,
defaultRuntime,
);
for (const warning of warnings) {
toast.warning(warning);
}
if (!runtime) {
throw new Error("No available runtime found for this agent.");
}
const input: CreateManagedAgentInput = {
name: personaToStart.displayName,
acpCommand: "buzz-acp",
agentCommand: runtime.command,
agentArgs: runtime.defaultArgs,
mcpCommand: runtime.mcpCommand ?? "",
personaId: personaToStart.id,
systemPrompt: personaToStart.systemPrompt,
avatarUrl: personaToStart.avatarUrl ?? undefined,
model: personaToStart.model ?? undefined,
envVars: personaToStart.envVars,
spawnAfterCreate: true,
startOnAppLaunch: true,
backend: { type: "local" },
};
const created = await createAgentMutation.mutateAsync(input);
void managedAgentsQuery.refetch();
void relayAgentsQuery.refetch();
return created;
},
[
availableRuntimesQuery.data,
createAgentMutation.mutateAsync,
managedAgentsQuery.refetch,
relayAgentsQuery.refetch,
],
);
const { deleteManagedAgentRecord } = useProfileAgentDeletion({
channels: channelsQuery.data,
deleteManagedAgent: deleteAgentMutation.mutateAsync,
presenceLookup: presenceQuery.data,
relayAgents: relayAgentsQuery.data,
});
const handleAgentPrimaryAction = React.useCallback(async () => {
if (!managedAgent) return;
@@ -485,26 +367,6 @@ export function UserProfilePanel({
stopAgentMutation.mutateAsync,
]);
const handleInstantiateAgent = React.useCallback(async () => {
if (!resolvedPersona) return;
try {
const created = await createManagedAgentForPersona(resolvedPersona);
if (created.spawnError) {
toast.error(created.spawnError);
} else {
toast.success(`Started ${created.agent.name}.`);
}
if (created.profileSyncError) {
toast.warning(created.profileSyncError);
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to start agent.",
);
}
}, [createManagedAgentForPersona, resolvedPersona]);
const handleToggleAgentAutoStart = React.useCallback(async () => {
if (managedAgent?.backend.type !== "local") return;
@@ -543,51 +405,19 @@ export function UserProfilePanel({
}
}, [deleteManagedAgentRecord, managedAgent, onClose]);
const handleSubmitPersona = React.useCallback(
async (input: CreatePersonaInput | UpdatePersonaInput) => {
await submitProfilePersonaDialog({
createManagedAgentForPersona,
createPersona: createPersonaMutation.mutateAsync,
input,
managedAgent,
onDone: () => {
setPersonaDialogState(null);
void personasQuery.refetch();
},
previousPersona: resolvedPersona,
runtimes: acpRuntimesQuery.data ?? [],
updateManagedAgent: updateManagedAgentMutation.mutateAsync,
updatePersona: updatePersonaMutation.mutateAsync,
});
},
[
createPersonaMutation.mutateAsync,
createManagedAgentForPersona,
managedAgent,
personasQuery.refetch,
resolvedPersona,
acpRuntimesQuery.data,
updateManagedAgentMutation.mutateAsync,
updatePersonaMutation.mutateAsync,
],
);
const handleDuplicateAgent = React.useCallback(() => {
if (!managedAgent) return;
requestOpenCreateAgent(duplicateAgentDraft(managedAgent));
onClose();
void goAgents();
}, [goAgents, managedAgent, onClose]);
const handleEditPersona = React.useCallback(() => {
if (!resolvedPersona || resolvedPersona.isBuiltIn) return;
setPersonaDialogState(editPersonaDialogState(resolvedPersona));
}, [resolvedPersona]);
const handleDuplicatePersona = React.useCallback(() => {
if (!resolvedPersona) return;
setPersonaDialogState(duplicatePersonaDialogState(resolvedPersona));
}, [resolvedPersona]);
const handleExportPersona = React.useCallback(() => {
if (!resolvedPersona) return;
exportPersonaJsonMutation.mutate(resolvedPersona.id, {
const handleExportAgent = React.useCallback(() => {
if (!managedAgent) return;
exportAgentJsonMutation.mutate(managedAgent.pubkey, {
onSuccess: (saved) => {
if (saved) {
toast.success(`Exported ${resolvedPersona.displayName}.`);
toast.success(`Exported ${managedAgent.name}.`);
}
},
onError: (error) => {
@@ -596,69 +426,7 @@ export function UserProfilePanel({
);
},
});
}, [exportPersonaJsonMutation, resolvedPersona]);
const handleDeletePersona = React.useCallback(async () => {
if (!resolvedPersona) return;
if (resolvedPersona.isBuiltIn) {
try {
const deletedInstances =
await deleteManagedAgentsForPersona(resolvedPersona);
if (deletedInstances.cancelled) return;
await setPersonaActiveMutation.mutateAsync({
id: resolvedPersona.id,
active: false,
});
toast.success(`Removed ${resolvedPersona.displayName} from My Agents.`);
onClose();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to delete agent.",
);
}
return;
}
if (resolvedPersona.sourceTeam) {
toast.error("This agent is managed by a team.");
return;
}
setPersonaToDelete(resolvedPersona);
}, [
deleteManagedAgentsForPersona,
onClose,
resolvedPersona,
setPersonaActiveMutation.mutateAsync,
]);
const handleConfirmDeletePersona = React.useCallback(
async (personaToConfirm: AgentPersona) => {
if (personaToConfirm.sourceTeam) {
toast.error("This agent is managed by a team.");
setPersonaToDelete(null);
return;
}
try {
const deletedInstances =
await deleteManagedAgentsForPersona(personaToConfirm);
if (deletedInstances.cancelled) return;
await deletePersonaMutation.mutateAsync(personaToConfirm.id);
toast.success(`Deleted ${personaToConfirm.displayName}.`);
setPersonaToDelete(null);
onClose();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to delete agent.",
);
}
},
[deleteManagedAgentsForPersona, deletePersonaMutation.mutateAsync, onClose],
);
}, [exportAgentJsonMutation, managedAgent]);
const handleAddedToChannel = React.useCallback(
(channel: Channel, result: AttachManagedAgentToChannelResult) => {
@@ -741,26 +509,19 @@ export function UserProfilePanel({
managedAgent,
resolvedPersona,
);
const canManagePersona = isOwner === true && resolvedPersona !== undefined;
const canEditPersona =
canManagePersona && resolvedPersona?.isBuiltIn !== true;
const canDeletePersona = canManagePersona && !resolvedPersona?.sourceTeam;
const canManageAgent = isOwner === true && managedAgent !== undefined;
const archiveActions = useIdentityArchive(effectivePubkey);
const agentSettingsMenu = (
<UserProfileAgentSettingsMenuSlot
archiveActions={archiveActions}
canDeletePersona={canDeletePersona}
canInstantiateAgent={canInstantiateAgent}
canManagePersona={canManagePersona}
canManageAgent={canManageAgent}
isAgentActionPending={isAgentActionPending}
isBot={isBot}
managedAgent={managedAgent}
onDeleteAgent={handleDeleteAgent}
onDeletePersona={handleDeletePersona}
onDuplicatePersona={handleDuplicatePersona}
onExportPersona={handleExportPersona}
onDuplicateAgent={handleDuplicateAgent}
onExportAgent={handleExportAgent}
onToggleAutoStart={handleToggleAgentAutoStart}
personaActionKey={resolvedPersona?.id}
viewerIsOwner={viewerIsOwner}
/>
);
@@ -813,7 +574,6 @@ export function UserProfilePanel({
<ProfileSummaryView
canAddToChannel={managedAgent !== undefined && isOwner === true}
canEditAgent={canEditAgent}
canInstantiateAgent={canInstantiateAgent}
canOpenAgentLogs={canOpenAgentLogs}
canViewActivity={canViewActivity}
channelCount={profileChannels.length}
@@ -825,8 +585,6 @@ export function UserProfilePanel({
agentInstruction={agentInstruction}
handleAgentPrimaryAction={handleAgentPrimaryAction}
handleEditAgent={handleEditAgent}
handleEditPersona={canEditPersona ? handleEditPersona : undefined}
handleInstantiateAgent={handleInstantiateAgent}
handleMessage={handleMessage}
isArchived={archiveActions.isArchived === true}
isMessagePending={isOpeningDm}
@@ -928,36 +686,6 @@ export function UserProfilePanel({
open={addToChannelOpen}
/>
) : null;
const personaDialogs = (
<UserProfilePersonaDialogs
createError={
createPersonaMutation.error instanceof Error
? createPersonaMutation.error
: null
}
isPending={
createPersonaMutation.isPending ||
updatePersonaMutation.isPending ||
updateManagedAgentMutation.isPending ||
createAgentMutation.isPending
}
personaDialogState={personaDialogState}
personaToDelete={personaToDelete}
runtimes={acpRuntimesQuery.data ?? []}
runtimesLoading={acpRuntimesQuery.isLoading}
updateError={
updatePersonaMutation.error instanceof Error
? updatePersonaMutation.error
: null
}
onCloseDelete={() => setPersonaToDelete(null)}
onCloseDialog={() => setPersonaDialogState(null)}
onConfirmDelete={(selectedPersona) => {
void handleConfirmDeletePersona(selectedPersona);
}}
onSubmit={handleSubmitPersona}
/>
);
return (
<UserProfilePanelFrame
addAgentToChannelDialog={addAgentToChannelDialog}
@@ -971,7 +699,6 @@ export function UserProfilePanel({
onClose={onClose}
onResetWidth={onResetWidth}
onResizeStart={onResizeStart}
personaDialogs={personaDialogs}
profileBody={profileBody}
splitPaneClamp={splitPaneClamp}
widthPx={widthPx}

View File

@@ -6,7 +6,6 @@ import {
} from "@/features/agents/lib/managedAgentControlActions";
import { removeChannelMember } from "@/shared/api/tauri";
import type {
AgentPersona,
Channel,
ManagedAgent,
PresenceLookup,
@@ -23,17 +22,9 @@ type DeleteProfileManagedAgentContext = DeleteManagedAgentRulesContext & {
removeAgentFromAllChannels: (pubkey: string) => Promise<void>;
};
type DeleteProfileManagedAgentsForPersonaContext =
DeleteProfileManagedAgentContext & {
managedAgents: readonly ManagedAgent[];
selectedAgent?: ManagedAgent;
};
type UseProfileAgentDeletionInput = {
channels?: readonly Channel[];
deleteManagedAgent: DeleteManagedAgentRulesContext["deleteManagedAgent"];
managedAgent?: ManagedAgent;
managedAgents?: readonly ManagedAgent[];
presenceLookup?: PresenceLookup | null;
relayAgents?: readonly RelayAgent[];
};
@@ -41,8 +32,6 @@ type UseProfileAgentDeletionInput = {
export function useProfileAgentDeletion({
channels,
deleteManagedAgent,
managedAgent,
managedAgents,
presenceLookup,
relayAgents,
}: UseProfileAgentDeletionInput) {
@@ -90,31 +79,8 @@ export function useProfileAgentDeletion({
],
);
const deleteManagedAgentsForPersona = React.useCallback(
(persona: AgentPersona) =>
deleteProfileManagedAgentsForPersona(persona, {
channels: channels ?? [],
deleteManagedAgent,
managedAgents: managedAgents ?? [],
presenceLookup,
relayAgents: relayAgents ?? [],
removeAgentFromAllChannels,
selectedAgent: managedAgent,
}),
[
channels,
deleteManagedAgent,
managedAgent,
managedAgents,
presenceLookup,
relayAgents,
removeAgentFromAllChannels,
],
);
return {
deleteManagedAgentRecord,
deleteManagedAgentsForPersona,
removeAgentFromAllChannels,
};
}
@@ -133,28 +99,3 @@ export async function deleteProfileManagedAgent(
await removeAgentFromAllChannels(agent.pubkey);
return result;
}
export async function deleteProfileManagedAgentsForPersona(
persona: AgentPersona,
context: DeleteProfileManagedAgentsForPersonaContext,
): Promise<ManagedAgentActionResult> {
const { managedAgents, selectedAgent, ...deleteContext } = context;
const agentsByPubkey = new Map<string, ManagedAgent>();
for (const agent of managedAgents) {
if (agent.personaId === persona.id) {
agentsByPubkey.set(agent.pubkey, agent);
}
}
if (selectedAgent?.personaId === persona.id) {
agentsByPubkey.set(selectedAgent.pubkey, selectedAgent);
}
for (const agent of agentsByPubkey.values()) {
const result = await deleteProfileManagedAgent(agent, deleteContext);
if (result.cancelled) return result;
}
return {};
}

View File

@@ -15,7 +15,6 @@ type UserProfilePanelFrameProps = {
onClose: () => void;
onResetWidth?: () => void;
onResizeStart?: React.PointerEventHandler<HTMLButtonElement>;
personaDialogs: React.ReactNode;
profileBody: React.ReactNode;
splitPaneClamp: boolean;
widthPx: number;
@@ -34,7 +33,6 @@ export function UserProfilePanelFrame({
onClose,
onResetWidth,
onResizeStart,
personaDialogs,
profileBody,
splitPaneClamp,
widthPx,
@@ -54,7 +52,6 @@ export function UserProfilePanelFrame({
<>
{editAgentDialog}
{addAgentToChannelDialog}
{personaDialogs}
</>
}
splitPaneClamp={splitPaneClamp}

View File

@@ -1,138 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { validateLinkedAgentRuntimeEdit } from "./UserProfilePanelPersonaSubmit.ts";
function agent(overrides = {}) {
return {
pubkey: "deadbeef".repeat(8),
name: "Fizz",
personaId: "persona-1",
relayUrl: "ws://localhost:3000",
acpCommand: "buzz-acp",
agentCommand: "goose",
agentArgs: [],
mcpCommand: "",
turnTimeoutSeconds: 320,
idleTimeoutSeconds: null,
maxTurnDurationSeconds: null,
parallelism: 1,
systemPrompt: "Prompt",
avatarUrl: null,
model: null,
mcpToolsets: null,
envVars: {},
status: "stopped",
pid: null,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
lastStartedAt: null,
lastStoppedAt: null,
lastExitCode: null,
lastError: null,
logPath: null,
startOnAppLaunch: true,
backend: { type: "local" },
backendAgentId: null,
respondTo: "owner-only",
respondToAllowlist: [],
...overrides,
};
}
function persona(overrides = {}) {
return {
id: "persona-1",
displayName: "Fizz",
avatarUrl: null,
systemPrompt: "Prompt",
runtime: "goose",
model: null,
provider: null,
namePool: [],
isBuiltIn: false,
isActive: true,
envVars: {},
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
};
}
function updateInput(overrides = {}) {
return {
id: "persona-1",
displayName: "Fizz",
avatarUrl: undefined,
systemPrompt: "Prompt",
runtime: "claude",
model: undefined,
provider: undefined,
namePool: [],
...overrides,
};
}
function runtime(overrides = {}) {
return {
id: "claude",
label: "Claude Code",
avatarUrl: "",
availability: "available",
command: "claude",
binaryPath: "/usr/local/bin/claude",
defaultArgs: [],
mcpCommand: null,
installHint: "",
installInstructionsUrl: "",
canAutoInstall: false,
underlyingCliPath: null,
...overrides,
};
}
test("validateLinkedAgentRuntimeEdit allows available runtime changes", () => {
assert.equal(
validateLinkedAgentRuntimeEdit({
input: updateInput({ runtime: "claude" }),
managedAgent: agent(),
previousPersona: persona({ runtime: "goose" }),
runtimes: [runtime()],
}),
null,
);
});
test("validateLinkedAgentRuntimeEdit rejects unavailable linked-agent runtime changes", () => {
assert.equal(
validateLinkedAgentRuntimeEdit({
input: updateInput({ runtime: "claude" }),
managedAgent: agent(),
previousPersona: persona({ runtime: "goose" }),
runtimes: [runtime({ availability: "cli_missing", command: null })],
}),
"Claude Code is not available. Install it before saving this linked agent.",
);
});
test("validateLinkedAgentRuntimeEdit allows unchanged or unlinked runtime preferences", () => {
assert.equal(
validateLinkedAgentRuntimeEdit({
input: updateInput({ runtime: "goose" }),
managedAgent: agent(),
previousPersona: persona({ runtime: "goose" }),
runtimes: [],
}),
null,
);
assert.equal(
validateLinkedAgentRuntimeEdit({
input: updateInput({ runtime: "claude" }),
managedAgent: undefined,
previousPersona: persona({ runtime: "goose" }),
runtimes: [],
}),
null,
);
});

View File

@@ -1,135 +0,0 @@
import { toast } from "sonner";
import { personaManagedAgentUpdate } from "@/features/profile/ui/UserProfilePanelUtils";
import type {
AcpRuntimeCatalogEntry,
AgentPersona,
CreateManagedAgentResponse,
CreatePersonaInput,
ManagedAgent,
UpdateManagedAgentInput,
UpdatePersonaInput,
} from "@/shared/api/types";
type SubmitProfilePersonaDialogOptions = {
createManagedAgentForPersona: (
persona: AgentPersona,
) => Promise<CreateManagedAgentResponse>;
createPersona: (input: CreatePersonaInput) => Promise<AgentPersona>;
input: CreatePersonaInput | UpdatePersonaInput;
managedAgent: ManagedAgent | undefined;
onDone: () => void;
previousPersona?: AgentPersona;
runtimes?: readonly AcpRuntimeCatalogEntry[];
updateManagedAgent: (
input: UpdateManagedAgentInput,
) => Promise<{ agent: ManagedAgent; profileSyncError: string | null }>;
updatePersona: (input: UpdatePersonaInput) => Promise<AgentPersona>;
};
type ValidateLinkedAgentRuntimeEditOptions = {
input: UpdatePersonaInput;
managedAgent: ManagedAgent | undefined;
previousPersona?: AgentPersona;
runtimes?: readonly AcpRuntimeCatalogEntry[];
};
function normalizeRuntimePreference(value: string | null | undefined): string {
return value?.trim() ?? "";
}
export function validateLinkedAgentRuntimeEdit({
input,
managedAgent,
previousPersona,
runtimes,
}: ValidateLinkedAgentRuntimeEditOptions): string | null {
if (!managedAgent || !previousPersona) {
return null;
}
const previousRuntime = normalizeRuntimePreference(previousPersona.runtime);
const nextRuntime = normalizeRuntimePreference(input.runtime);
if (previousRuntime === nextRuntime) {
return null;
}
const runtime = runtimes?.find((candidate) => candidate.id === nextRuntime);
if (runtime?.availability === "available" && runtime.command) {
return null;
}
const runtimeLabel = runtime?.label ?? "This provider";
return `${runtimeLabel} is not available. Install it before saving this linked agent.`;
}
export async function submitProfilePersonaDialog({
createManagedAgentForPersona,
createPersona,
input,
managedAgent,
onDone,
previousPersona,
runtimes,
updateManagedAgent,
updatePersona,
}: SubmitProfilePersonaDialogOptions) {
try {
if ("id" in input) {
const runtimeEditError = validateLinkedAgentRuntimeEdit({
input,
managedAgent,
previousPersona,
runtimes,
});
if (runtimeEditError) {
toast.error(runtimeEditError);
return;
}
const persona = await updatePersona(input);
const agentUpdate = managedAgent
? personaManagedAgentUpdate(managedAgent, persona, {
previousPersona,
runtimes,
})
: null;
const result = agentUpdate ? await updateManagedAgent(agentUpdate) : null;
if (result?.profileSyncError) {
toast.warning(
`${result.agent.name} was updated, but profile sync failed: ${result.profileSyncError}`,
);
}
toast.success(`Updated ${input.displayName}.`);
} else {
const persona = await createPersona(input);
try {
const created = await createManagedAgentForPersona(persona);
if (created.spawnError) {
toast.error(
`${persona.displayName} was created, but it did not start: ${created.spawnError}`,
);
} else {
toast.success(`Created and started ${created.agent.name}.`);
}
if (created.profileSyncError) {
toast.warning(
`${created.agent.name} was created, but profile sync failed: ${created.profileSyncError}`,
);
}
} catch (error) {
toast.error(
error instanceof Error
? `${persona.displayName} was created, but the agent instance could not be created: ${error.message}`
: `${persona.displayName} was created, but the agent instance could not be created.`,
);
}
}
onDone();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to save agent.",
);
}
}

View File

@@ -74,12 +74,9 @@ export type ProfileSummaryViewProps = {
channelsLoading: boolean;
displayName: string;
followMutation: ReturnType<typeof useFollowMutation>;
canInstantiateAgent: boolean;
agentInstruction: string | null;
handleAgentPrimaryAction: () => void;
handleEditAgent: () => void;
handleEditPersona?: () => void;
handleInstantiateAgent: () => void;
handleMessage: () => void;
isArchived: boolean;
isMessagePending: boolean;
@@ -182,12 +179,9 @@ export function ProfileSummaryView({
channelsLoading,
displayName,
followMutation,
canInstantiateAgent,
agentInstruction,
handleAgentPrimaryAction,
handleEditAgent,
handleEditPersona,
handleInstantiateAgent,
handleMessage,
isArchived,
isMessagePending,
@@ -221,9 +215,7 @@ export function ProfileSummaryView({
const activeTurns = useActiveAgentTurns(isBot ? pubkey : null);
const showMemoriesTab = isOwner === true && Boolean(pubkey);
const showInstructionBlock =
isOwner === true &&
(agentInstruction !== null || handleEditPersona !== undefined);
const showInstructionBlock = isOwner === true && agentInstruction !== null;
const showChannelsTab =
channelsLoading || channelCount > 0 || isBot || relayAgent !== undefined;
const runtimeConfigurationFields = agentSettingsFields.filter((field) =>
@@ -336,14 +328,7 @@ export function ProfileSummaryView({
userStatus={userStatus}
/>
{canInstantiateAgent ? (
<ProfilePersonaPrimaryActions
canEditAgent={canEditAgent}
disabled={isAgentActionPending}
onEditAgent={handleEditAgent}
onStartAgent={handleInstantiateAgent}
/>
) : !isSelf && pubkey ? (
{!isSelf && pubkey ? (
<ProfilePrimaryActions
canEditAgent={canEditAgent}
followMutation={followMutation}
@@ -724,39 +709,6 @@ function ProfilePrimaryActions({
);
}
function ProfilePersonaPrimaryActions({
canEditAgent,
disabled,
onEditAgent,
onStartAgent,
}: {
canEditAgent: boolean;
disabled: boolean;
onEditAgent: () => void;
onStartAgent: () => void;
}) {
return (
<div className="flex items-center justify-center gap-8">
<ProfileQuickAction
disabled={disabled}
icon={Play}
label="Start agent"
onClick={onStartAgent}
testId="user-profile-start-agent"
/>
{canEditAgent ? (
<ProfileQuickAction
disabled={disabled}
icon={Pencil}
label="Edit"
onClick={onEditAgent}
testId="user-profile-edit-agent"
/>
) : null}
</div>
);
}
function ProfileQuickAction({
active,
disabled,

View File

@@ -4,155 +4,10 @@ import test from "node:test";
import {
parseProfilePanelTab,
parseProfilePanelView,
personaManagedAgentUpdate,
profilePanelTabFromSearch,
profilePanelViewFromSearch,
} from "./UserProfilePanelUtils.ts";
function agent(overrides = {}) {
return {
pubkey: "deadbeef".repeat(8),
name: "Fizz",
personaId: "persona-1",
relayUrl: "ws://localhost:3000",
acpCommand: "buzz-acp",
agentCommand: "goose",
agentArgs: [],
mcpCommand: "",
turnTimeoutSeconds: 320,
idleTimeoutSeconds: null,
maxTurnDurationSeconds: null,
parallelism: 1,
systemPrompt: "Old prompt",
avatarUrl: "app-avatar://old",
model: "old-model",
mcpToolsets: null,
envVars: { OLD_KEY: "1" },
status: "stopped",
pid: null,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
lastStartedAt: null,
lastStoppedAt: null,
lastExitCode: null,
lastError: null,
logPath: null,
startOnAppLaunch: true,
backend: { type: "local" },
backendAgentId: null,
respondTo: "owner-only",
respondToAllowlist: [],
...overrides,
};
}
function persona(overrides = {}) {
return {
id: "persona-1",
displayName: "Fizz Prime",
avatarUrl: null,
systemPrompt: "New prompt",
runtime: "goose",
model: "new-model",
provider: null,
namePool: [],
isBuiltIn: false,
isActive: true,
envVars: { NEW_KEY: "2" },
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
};
}
function runtime(overrides = {}) {
return {
id: "claude",
label: "Claude Code",
avatarUrl: "app-avatar://claude",
availability: "available",
command: "claude",
binaryPath: "/usr/local/bin/claude",
defaultArgs: ["mcp", "serve"],
mcpCommand: "claude-mcp",
installHint: "",
installInstructionsUrl: "",
canAutoInstall: false,
underlyingCliPath: null,
...overrides,
};
}
test("personaManagedAgentUpdate syncs edited persona identity to linked agent", () => {
assert.deepEqual(personaManagedAgentUpdate(agent(), persona()), {
pubkey: "deadbeef".repeat(8),
name: "Fizz Prime",
systemPrompt: "New prompt",
model: "new-model",
envVars: { NEW_KEY: "2" },
});
});
test("personaManagedAgentUpdate skips unrelated or unchanged agents", () => {
assert.equal(
personaManagedAgentUpdate(agent({ personaId: "persona-2" }), persona()),
null,
);
assert.equal(
personaManagedAgentUpdate(
agent({
name: "Fizz Prime",
avatarUrl: null,
systemPrompt: "New prompt",
model: "new-model",
envVars: { NEW_KEY: "2" },
}),
persona(),
),
null,
);
});
test("personaManagedAgentUpdate maps changed persona runtime to linked agent commands", () => {
assert.deepEqual(
personaManagedAgentUpdate(agent(), persona({ runtime: "claude" }), {
previousPersona: persona({ runtime: "goose" }),
runtimes: [runtime()],
}),
{
pubkey: "deadbeef".repeat(8),
name: "Fizz Prime",
systemPrompt: "New prompt",
model: "new-model",
envVars: { NEW_KEY: "2" },
agentCommand: "claude",
agentArgs: ["mcp", "serve"],
mcpCommand: "claude-mcp",
},
);
});
test("personaManagedAgentUpdate leaves runtime fields alone when runtime is unchanged", () => {
assert.equal(
personaManagedAgentUpdate(
agent({
name: "Fizz Prime",
avatarUrl: null,
systemPrompt: "New prompt",
model: "new-model",
envVars: { NEW_KEY: "2" },
agentArgs: ["custom"],
}),
persona({ runtime: "goose" }),
{
previousPersona: persona({ runtime: "goose" }),
runtimes: [runtime({ id: "goose", command: "goose" })],
},
),
null,
);
});
test("parseProfilePanelView accepts all profile panel subviews", () => {
for (const view of [
"summary",

View File

@@ -1,12 +1,10 @@
import * as React from "react";
import type {
AcpRuntimeCatalogEntry,
AgentPersona,
Channel,
ManagedAgent,
Profile,
RelayAgent,
UpdateManagedAgentInput,
} from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { truncatePubkey } from "@/features/profile/lib/identity";
@@ -257,83 +255,6 @@ export function resolveAgentInstruction(
);
}
export function personaManagedAgentUpdate(
agent: ManagedAgent,
persona: AgentPersona,
options: {
previousPersona?: AgentPersona;
runtimes?: readonly AcpRuntimeCatalogEntry[];
} = {},
): UpdateManagedAgentInput | null {
if (agent.personaId !== persona.id) return null;
const input: UpdateManagedAgentInput = { pubkey: agent.pubkey };
let hasChanges = false;
if (persona.displayName !== agent.name) {
input.name = persona.displayName;
hasChanges = true;
}
if (persona.systemPrompt !== (agent.systemPrompt ?? "")) {
input.systemPrompt = persona.systemPrompt;
hasChanges = true;
}
if ((persona.model ?? null) !== (agent.model ?? null)) {
input.model = persona.model;
hasChanges = true;
}
if (!stringRecordEqual(persona.envVars, agent.envVars)) {
input.envVars = persona.envVars;
hasChanges = true;
}
const runtimeChanged =
options.previousPersona !== undefined &&
options.previousPersona.runtime !== persona.runtime;
const runtime = runtimeChanged
? options.runtimes?.find((candidate) => candidate.id === persona.runtime)
: undefined;
if (runtime?.command) {
if (runtime.command !== agent.agentCommand) {
input.agentCommand = runtime.command;
hasChanges = true;
}
if (!stringArrayEqual(runtime.defaultArgs, agent.agentArgs)) {
input.agentArgs = [...runtime.defaultArgs];
hasChanges = true;
}
const mcpCommand = runtime.mcpCommand ?? "";
if (mcpCommand !== agent.mcpCommand) {
input.mcpCommand = mcpCommand;
hasChanges = true;
}
}
return hasChanges ? input : null;
}
function stringArrayEqual(left: readonly string[], right: readonly string[]) {
if (left.length !== right.length) return false;
return left.every((value, index) => value === right[index]);
}
function stringRecordEqual(
left: Record<string, string>,
right: Record<string, string>,
) {
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) return false;
return leftKeys.every((key) => left[key] === right[key]);
}
export function useRetainedPersona(
sourcePersona: AgentPersona | undefined,
profileIdentityKey: string,

View File

@@ -1,67 +0,0 @@
import type {
AcpRuntimeCatalogEntry,
AgentPersona,
CreatePersonaInput,
UpdatePersonaInput,
} from "@/shared/api/types";
import { PersonaDeleteDialog } from "@/features/agents/ui/PersonaDeleteDialog";
import { PersonaDialog } from "@/features/agents/ui/PersonaDialog";
import type { PersonaDialogState } from "@/features/agents/ui/personaDialogState";
export function UserProfilePersonaDialogs({
createError,
isPending,
personaDialogState,
personaToDelete,
runtimes,
runtimesLoading,
updateError,
onCloseDelete,
onCloseDialog,
onConfirmDelete,
onSubmit,
}: {
createError: Error | null;
isPending: boolean;
personaDialogState: PersonaDialogState | null;
personaToDelete: AgentPersona | null;
runtimes: AcpRuntimeCatalogEntry[];
runtimesLoading: boolean;
updateError: Error | null;
onCloseDelete: () => void;
onCloseDialog: () => void;
onConfirmDelete: (persona: AgentPersona) => void;
onSubmit: (input: CreatePersonaInput | UpdatePersonaInput) => Promise<void>;
}) {
return (
<>
<PersonaDialog
description={personaDialogState?.description ?? ""}
error={updateError ?? createError}
initialValues={personaDialogState?.initialValues ?? null}
isPending={isPending}
runtimes={runtimes}
runtimesLoading={runtimesLoading}
onOpenChange={(open) => {
if (!open) {
onCloseDialog();
}
}}
onSubmit={onSubmit}
open={personaDialogState !== null}
submitLabel={personaDialogState?.submitLabel ?? "Save"}
title={personaDialogState?.title ?? "Agent"}
/>
<PersonaDeleteDialog
onConfirm={onConfirmDelete}
onOpenChange={(open) => {
if (!open) {
onCloseDelete();
}
}}
open={personaToDelete !== null}
persona={personaToDelete}
/>
</>
);
}

View File

@@ -12,7 +12,7 @@ import { toast } from "sonner";
import {
useAvailableAcpRuntimes,
usePersonasQuery,
useManagedAgentsQuery,
useTeamsQuery,
} from "@/features/agents/hooks";
import {
@@ -22,11 +22,10 @@ import {
useDuplicateChannelTemplateMutation,
useUpdateChannelTemplateMutation,
} from "@/features/channel-templates/hooks";
import { AddChannelBotPersonasSection } from "@/features/channels/ui/AddChannelBotPersonasSection";
import { AddChannelBotAgentsSection } from "@/features/channels/ui/AddChannelBotAgentsSection";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type {
AcpRuntime,
AgentPersona,
AgentTeam,
ChannelTemplate,
CreateChannelTemplateInput,
@@ -281,7 +280,7 @@ function TemplateFormDialog({
const isEditing = template !== null;
const createMutation = useCreateChannelTemplateMutation();
const updateMutation = useUpdateChannelTemplateMutation();
const personasQuery = usePersonasQuery();
const managedAgentsQuery = useManagedAgentsQuery();
const teamsQuery = useTeamsQuery();
const providersQuery = useAvailableAcpRuntimes();
const runtimes = providersQuery.data ?? [];
@@ -289,13 +288,13 @@ function TemplateFormDialog({
const [name, setName] = React.useState("");
const [description, setDescription] = React.useState("");
const [canvasTemplate, setCanvasTemplate] = React.useState("");
const [selectedPersonaIds, setSelectedPersonaIds] = React.useState<string[]>(
// Agent member refs, stored in the template's `personaId` wire field.
// Legacy templates may carry persona ids or built-in template ids here;
// the apply path resolves all three.
const [selectedAgentRefs, setSelectedAgentRefs] = React.useState<string[]>(
[],
);
const [selectedTeamIds, setSelectedTeamIds] = React.useState<string[]>([]);
const [personaRuntimes, setPersonaRuntimes] = React.useState<
Record<string, string>
>({});
const [teamRuntimes, setTeamRuntimes] = React.useState<
Record<string, string>
>({});
@@ -308,13 +307,8 @@ function TemplateFormDialog({
setName(template.name);
setDescription(template.description ?? "");
setCanvasTemplate(template.canvasTemplate ?? "");
setSelectedPersonaIds(template.agents.personas.map((p) => p.personaId));
setSelectedAgentRefs(template.agents.personas.map((p) => p.personaId));
setSelectedTeamIds(template.agents.teams.map((t) => t.teamId));
const pRuntimes: Record<string, string> = {};
for (const p of template.agents.personas) {
if (p.runtime) pRuntimes[p.personaId] = p.runtime;
}
setPersonaRuntimes(pRuntimes);
const tRuntimes: Record<string, string> = {};
for (const t of template.agents.teams) {
if (t.runtime) tRuntimes[t.teamId] = t.runtime;
@@ -324,9 +318,8 @@ function TemplateFormDialog({
setName("");
setDescription("");
setCanvasTemplate("");
setSelectedPersonaIds([]);
setSelectedAgentRefs([]);
setSelectedTeamIds([]);
setPersonaRuntimes({});
setTeamRuntimes({});
}
}, [open, template]);
@@ -337,9 +330,9 @@ function TemplateFormDialog({
if (!trimmedName) return;
const agents = {
personas: selectedPersonaIds.map((personaId) => ({
personas: selectedAgentRefs.map((personaId) => ({
personaId,
runtime: personaRuntimes[personaId] || null,
runtime: null,
model: null,
role: null,
backend: null,
@@ -394,18 +387,12 @@ function TemplateFormDialog({
}
}
function handleTogglePersona(personaId: string) {
setSelectedPersonaIds((prev) => {
if (prev.includes(personaId)) {
setPersonaRuntimes((pp) => {
const next = { ...pp };
delete next[personaId];
return next;
});
return prev.filter((id) => id !== personaId);
}
return [...prev, personaId];
});
function handleToggleAgent(pubkey: string) {
setSelectedAgentRefs((prev) =>
prev.includes(pubkey)
? prev.filter((ref) => ref !== pubkey)
: [...prev, pubkey],
);
}
function handleToggleTeam(teamId: string) {
@@ -524,15 +511,15 @@ function TemplateFormDialog({
</p>
</div>
{/* Agent Personas */}
<AddChannelBotPersonasSection
{/* Agents */}
<AddChannelBotAgentsSection
agents={managedAgentsQuery.data ?? []}
canToggleSelections={!isPending}
includeGeneric={false}
isLoading={personasQuery.isLoading}
isLoading={managedAgentsQuery.isLoading}
onToggleAgent={handleToggleAgent}
onToggleGeneric={() => {}}
onTogglePersona={handleTogglePersona}
personas={personasQuery.data ?? []}
selectedPersonaIds={selectedPersonaIds}
selectedAgentPubkeys={selectedAgentRefs}
showGeneric={false}
/>
@@ -548,20 +535,11 @@ function TemplateFormDialog({
{/* Runtime assignments */}
<RuntimeAssignments
isPending={isPending}
personas={personasQuery.data ?? []}
personaRuntimes={personaRuntimes}
providers={runtimes}
providersLoading={providersQuery.isLoading}
selectedPersonaIds={selectedPersonaIds}
selectedTeamIds={selectedTeamIds}
teamRuntimes={teamRuntimes}
teams={teamsQuery.data ?? []}
onPersonaRuntimeChange={(personaId, runtimeId) =>
setPersonaRuntimes((prev) => ({
...prev,
[personaId]: runtimeId,
}))
}
onTeamRuntimeChange={(teamId, runtimeId) =>
setTeamRuntimes((prev) => ({ ...prev, [teamId]: runtimeId }))
}
@@ -632,36 +610,23 @@ function TemplateTeamSelector({
function RuntimeAssignments({
isPending,
onPersonaRuntimeChange,
onTeamRuntimeChange,
personas,
personaRuntimes,
providers,
providersLoading,
selectedPersonaIds,
selectedTeamIds,
teamRuntimes,
teams,
}: {
isPending: boolean;
onPersonaRuntimeChange: (personaId: string, runtimeId: string) => void;
onTeamRuntimeChange: (teamId: string, runtimeId: string) => void;
personas: AgentPersona[];
personaRuntimes: Record<string, string>;
providers: AcpRuntime[];
providersLoading: boolean;
selectedPersonaIds: readonly string[];
selectedTeamIds: readonly string[];
teamRuntimes: Record<string, string>;
teams: readonly AgentTeam[];
}) {
const hasSelections =
selectedPersonaIds.length > 0 || selectedTeamIds.length > 0;
if (!hasSelections) return null;
if (selectedTeamIds.length === 0) return null;
const selectedPersonas = personas.filter((p) =>
selectedPersonaIds.includes(p.id),
);
const selectedTeams = teams.filter((t) => selectedTeamIds.includes(t.id));
return (
@@ -669,7 +634,7 @@ function RuntimeAssignments({
<div>
<div className="text-sm font-medium">Runtimes</div>
<p className="text-sm font-normal text-muted-foreground">
Choose which runtime to use for each agent.
Choose which runtime to use for team-pack members.
</p>
</div>
@@ -681,19 +646,6 @@ function RuntimeAssignments({
</p>
) : (
<div className="space-y-2">
{selectedPersonas.map((persona) => (
<RuntimeRow
key={persona.id}
avatarUrl={persona.avatarUrl}
disabled={isPending}
label={persona.displayName}
onChange={(runtimeId) =>
onPersonaRuntimeChange(persona.id, runtimeId)
}
providers={providers}
value={personaRuntimes[persona.id] ?? ""}
/>
))}
{selectedTeams.map((team) => (
<RuntimeRow
key={team.id}

View File

@@ -1141,6 +1141,7 @@ export async function createManagedAgent(input: CreateManagedAgentInput) {
systemPrompt: input.systemPrompt,
avatarUrl: input.avatarUrl,
model: input.model,
provider: input.provider,
envVars: input.envVars ?? {},
spawnAfterCreate: input.spawnAfterCreate,
startOnAppLaunch: input.startOnAppLaunch,
@@ -1173,13 +1174,10 @@ export async function stopManagedAgent(pubkey: string): Promise<ManagedAgent> {
return fromRawManagedAgent(response);
}
export async function deleteManagedAgent(
pubkey: string,
forceRemoteDelete?: boolean,
): Promise<void> {
export async function deleteManagedAgent(pubkey: string, force?: boolean) {
await invokeTauri("delete_managed_agent", {
pubkey,
forceRemoteDelete: forceRemoteDelete ?? null,
forceRemoteDelete: force ?? null,
});
}

View File

@@ -3,7 +3,7 @@ import {
invokeTauri,
type RawManagedAgent,
} from "@/shared/api/tauri";
import type { ManagedAgent } from "@/shared/api/types";
import type { AgentTemplate, ManagedAgent } from "@/shared/api/types";
export async function setManagedAgentStartOnAppLaunch(
pubkey: string,
@@ -18,3 +18,16 @@ export async function setManagedAgentStartOnAppLaunch(
);
return fromRawManagedAgent(response);
}
/** Built-in starter templates for the Create Agent wizard (static data). */
export async function listAgentTemplates(): Promise<AgentTemplate[]> {
return invokeTauri<AgentTemplate[]>("list_agent_templates");
}
/**
* Export a managed agent's pinned config as a shareable `.persona.json` card.
* Returns `false` when the user cancels the save dialog.
*/
export async function exportAgentToJson(pubkey: string): Promise<boolean> {
return invokeTauri<boolean>("export_agent_to_json", { pubkey });
}

View File

@@ -9,7 +9,8 @@ type RawTeam = {
id: string;
name: string;
description: string | null;
persona_ids: string[];
persona_ids?: string[];
agent_pubkeys?: string[];
is_builtin?: boolean;
source_dir?: string | null;
is_symlink?: boolean;
@@ -24,7 +25,8 @@ function fromRawTeam(team: RawTeam): AgentTeam {
id: team.id,
name: team.name,
description: team.description,
personaIds: team.persona_ids,
personaIds: team.persona_ids ?? [],
agentPubkeys: team.agent_pubkeys ?? [],
isBuiltin: team.is_builtin ?? false,
sourceDir: team.source_dir ?? null,
isSymlink: team.is_symlink ?? false,
@@ -46,6 +48,7 @@ export async function createTeam(input: CreateTeamInput): Promise<AgentTeam> {
name: input.name,
description: input.description,
personaIds: input.personaIds,
agentPubkeys: input.agentPubkeys,
},
}),
);
@@ -59,6 +62,7 @@ export async function updateTeam(input: UpdateTeamInput): Promise<AgentTeam> {
name: input.name,
description: input.description,
personaIds: input.personaIds,
agentPubkeys: input.agentPubkeys,
},
}),
);

View File

@@ -385,6 +385,8 @@ export type CreateManagedAgentInput = {
systemPrompt?: string;
avatarUrl?: string;
model?: string;
/** LLM inference provider. A linked persona's snapshot provider wins. */
provider?: string;
mcpToolsets?: string;
envVars?: Record<string, string>;
spawnAfterCreate?: boolean;
@@ -645,12 +647,30 @@ export type UpdatePersonaInput = {
envVars?: Record<string, string>;
};
/**
* A built-in agent template: static starter data for the Create Agent
* wizard. Selecting a template prefills the create form — no persona record
* is created.
*/
export type AgentTemplate = {
id: string;
displayName: string;
avatarUrl: string | null;
systemPrompt: string;
runtime: string | null;
model: string | null;
namePool: string[];
};
// ── Team types ────────────────────────────────────────────────────────────────
export type AgentTeam = {
id: string;
name: string;
description: string | null;
/** Persona-backed members — pack-installed (directory-backed) teams only. */
personaIds: string[];
/** Managed-agent members by pubkey — the primary membership for user teams. */
agentPubkeys: string[];
isBuiltin: boolean;
/** Absolute path to the team's backing directory (if directory-backed). */
sourceDir: string | null;
@@ -668,6 +688,7 @@ export type CreateTeamInput = {
name: string;
description?: string;
personaIds: string[];
agentPubkeys: string[];
};
export type UpdateTeamInput = {
@@ -675,6 +696,7 @@ export type UpdateTeamInput = {
name: string;
description?: string;
personaIds: string[];
agentPubkeys: string[];
};
// ── Channel Template types ─────────────────────────────────────────────────────

View File

@@ -1,7 +1,5 @@
import * as React from "react";
import type { AgentPersona } from "@/shared/api/types";
export type ProfilePanelOpenOptions = {
tab?: "info" | "runtime" | "channels" | "memories";
};
@@ -10,32 +8,27 @@ type ProfilePanelContextValue = {
openProfilePanel:
| ((pubkey: string, options?: ProfilePanelOpenOptions) => void)
| null;
openPersonaProfilePanel: ((persona: AgentPersona) => void) | null;
};
const ProfilePanelContext = React.createContext<ProfilePanelContextValue>({
openProfilePanel: null,
openPersonaProfilePanel: null,
});
export function ProfilePanelProvider({
children,
onOpenProfilePanel,
onOpenPersonaProfilePanel,
}: {
children: React.ReactNode;
onOpenProfilePanel: (
pubkey: string,
options?: ProfilePanelOpenOptions,
) => void;
onOpenPersonaProfilePanel?: (persona: AgentPersona) => void;
}) {
const value = React.useMemo(
() => ({
openProfilePanel: onOpenProfilePanel,
openPersonaProfilePanel: onOpenPersonaProfilePanel ?? null,
}),
[onOpenPersonaProfilePanel, onOpenProfilePanel],
[onOpenProfilePanel],
);
return (

View File

@@ -494,6 +494,7 @@ type RawTeam = {
name: string;
description: string | null;
persona_ids: string[];
agent_pubkeys: string[];
is_builtin: boolean;
source_dir: string | null;
is_symlink: boolean;
@@ -1696,8 +1697,9 @@ function resetMockTeams() {
{
id: "team-engineering-001",
name: "Engineering",
description: "Core engineering personas",
description: "Core engineering agents",
persona_ids: [],
agent_pubkeys: [],
is_builtin: false,
source_dir: null,
is_symlink: false,
@@ -1711,6 +1713,7 @@ function resetMockTeams() {
name: "Research Agents",
description: "Directory-backed research team",
persona_ids: [],
agent_pubkeys: [],
is_builtin: false,
source_dir: "/Users/dev/agents/research",
is_symlink: false,
@@ -1724,6 +1727,7 @@ function resetMockTeams() {
name: "Platform Tools",
description: "Symlinked platform team",
persona_ids: [],
agent_pubkeys: [],
is_builtin: false,
source_dir: "/Users/dev/agents/platform",
is_symlink: true,
@@ -5863,9 +5867,7 @@ function ensureMockPersonaIsActive(personaId: string) {
throw new Error(`persona ${personaId} not found`);
}
if (!persona.is_active) {
throw new Error(
`${persona.display_name} is not in My Agents. Choose it from Agent Catalog first.`,
);
throw new Error(`${persona.display_name} is not available for new agents.`);
}
}
@@ -5875,10 +5877,67 @@ function ensureMockPersonaIdsAreActive(personaIds: string[]) {
}
}
type MockAgentTemplate = {
id: string;
displayName: string;
avatarUrl: string | null;
systemPrompt: string;
runtime: string | null;
model: string | null;
namePool: string[];
};
async function handleListAgentTemplates(): Promise<MockAgentTemplate[]> {
return [
{
id: "builtin:fizz",
displayName: "Fizz",
avatarUrl: null,
systemPrompt: "You are Fizz.",
runtime: "goose",
model: null,
namePool: ["Nectar", "Comet", "Bramble"],
},
{
id: "builtin:product-strategist",
displayName: "Product Strategist",
avatarUrl: BUILT_IN_PERSONA_AVATAR_URLS.productStrategist,
systemPrompt:
"You are a product strategy agent. You help turn broad ideas into clear product and design direction.",
runtime: null,
model: null,
namePool: ["Product Strategist"],
},
{
id: "builtin:qa-reviewer",
displayName: "QA Reviewer",
avatarUrl: BUILT_IN_PERSONA_AVATAR_URLS.qaReviewer,
systemPrompt:
"You are a QA reviewer agent. You look for the ways a change might break.",
runtime: null,
model: null,
namePool: ["QA Reviewer"],
},
];
}
async function handleExportAgentToJson(args: {
pubkey: string;
}): Promise<boolean> {
const agent = mockManagedAgents.find(
(candidate) => candidate.pubkey === args.pubkey,
);
if (!agent) {
throw new Error(`agent ${args.pubkey} not found`);
}
return true;
}
async function handleListTeams(): Promise<RawTeam[]> {
return mockTeams.map((team) => ({
...team,
persona_ids: [...team.persona_ids],
agent_pubkeys: [...team.agent_pubkeys],
}));
}
@@ -5887,6 +5946,7 @@ async function handleCreateTeam(args: {
name: string;
description?: string;
personaIds: string[];
agentPubkeys?: string[];
};
}): Promise<RawTeam> {
ensureMockPersonaIdsAreActive(args.input.personaIds);
@@ -5896,6 +5956,7 @@ async function handleCreateTeam(args: {
name: args.input.name.trim(),
description: args.input.description?.trim() || null,
persona_ids: [...args.input.personaIds],
agent_pubkeys: [...(args.input.agentPubkeys ?? [])],
is_builtin: false,
source_dir: null,
is_symlink: false,
@@ -5905,7 +5966,11 @@ async function handleCreateTeam(args: {
updated_at: now,
};
mockTeams.push(team);
return { ...team, persona_ids: [...team.persona_ids] };
return {
...team,
persona_ids: [...team.persona_ids],
agent_pubkeys: [...team.agent_pubkeys],
};
}
async function handleUpdateTeam(args: {
@@ -5914,6 +5979,7 @@ async function handleUpdateTeam(args: {
name: string;
description?: string;
personaIds: string[];
agentPubkeys?: string[];
};
}): Promise<RawTeam> {
const team = mockTeams.find((candidate) => candidate.id === args.input.id);
@@ -5925,9 +5991,14 @@ async function handleUpdateTeam(args: {
team.name = args.input.name.trim();
team.description = args.input.description?.trim() || null;
team.persona_ids = [...args.input.personaIds];
team.agent_pubkeys = [...(args.input.agentPubkeys ?? [])];
team.updated_at = new Date().toISOString();
return { ...team, persona_ids: [...team.persona_ids] };
return {
...team,
persona_ids: [...team.persona_ids],
agent_pubkeys: [...team.agent_pubkeys],
};
}
async function handleDeleteTeam(args: { id: string }): Promise<void> {
@@ -5971,6 +6042,7 @@ async function handleInstallTeamFromDirectory(args: {
name: "Installed Team",
description: null,
persona_ids: [],
agent_pubkeys: [],
is_builtin: false,
source_dir: args.path,
is_symlink: args.symlink,
@@ -5980,7 +6052,11 @@ async function handleInstallTeamFromDirectory(args: {
updated_at: now,
};
mockTeams.push(team);
return { ...team, persona_ids: [...team.persona_ids] };
return {
...team,
persona_ids: [...team.persona_ids],
agent_pubkeys: [...team.agent_pubkeys],
};
}
async function handleSyncTeamDirectory(args: { teamId: string }): Promise<{
@@ -7775,6 +7851,10 @@ export function maybeInstallE2eTauriMocks() {
);
case "export_persona_to_json":
return handleExportPersonaToJson(payload as { id: string });
case "list_agent_templates":
return handleListAgentTemplates();
case "export_agent_to_json":
return handleExportAgentToJson(payload as { pubkey: string });
case "list_managed_agents":
return handleListManagedAgents(activeConfig);
case "get_agent_memory":