mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
149
desktop/src/features/agents/lib/teamMembers.ts
Normal file
149
desktop/src/features/agents/lib/teamMembers.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
85
desktop/src/features/agents/ui/AgentActionsMenu.tsx
Normal file
85
desktop/src/features/agents/ui/AgentActionsMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
342
desktop/src/features/agents/ui/AgentModelProviderFields.tsx
Normal file
342
desktop/src/features/agents/ui/AgentModelProviderFields.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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 > 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'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 > 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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
169
desktop/src/features/agents/ui/CreateAgentStartDialog.tsx
Normal file
169
desktop/src/features/agents/ui/CreateAgentStartDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 > 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
117
desktop/src/features/agents/ui/agentDraft.ts
Normal file
117
desktop/src/features/agents/ui/agentDraft.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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) !==
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
@@ -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'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'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"],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
25
desktop/src/features/channels/ui/useInChannelAgentPubkeys.ts
Normal file
25
desktop/src/features/channels/ui/useInChannelAgentPubkeys.ts
Normal 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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user