Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,13 @@ const overrides = new Map([
["src/features/huddle/HuddleContext.tsx", 650], // huddle lifecycle context + joinHuddle + connectAndSetupMedia shared helper + activeSpeakers/isReconnecting state + PTT (reusable AudioContext) + TTS subscription + mic level analyser (10fps throttle) + agent pubkey refresh
["src/features/agents/hooks.ts", 540], // agent query/mutation surface now includes built-in persona library activation + useUpdateManagedAgentMutation
["src/features/agents/ui/AgentsView.tsx", 880], // remote agent lifecycle controls + persona/team management + persona import-update dialog wiring + built-in catalog/library state orchestration
["src/features/agents/ui/UnifiedAgentsSection.tsx", 570], // unified persona-grouped agent view with collapsible groups, bulk actions, drag-drop import, empty/loading states
["src/features/agents/ui/ManagedAgentRow.tsx", 530], // EditAgentDialog integration + provider/local branching
["src/features/agents/ui/TeamDialog.tsx", 530], // team create/edit dialog with persona multi-select, import button, window drag detection, removal confirmation
["src/features/agents/ui/TeamImportUpdateDialog.tsx", 660], // team import diff preview with member matching/updating/adding/removing sections, LCS line counts, removal confirmation
["src/features/agents/ui/useTeamActions.ts", 510], // team CRUD + export + import + import-update orchestration with query invalidation
["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes
["src/features/channels/ui/AddChannelBotDialog.tsx", 640], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display
["src/features/channels/ui/AddChannelBotDialog.tsx", 660], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display + RespondTo field
["src/features/settings/ui/ChannelTemplatesSettingsCard.tsx", 850], // template CRUD card + TemplateFormDialog (persona/team chip selectors + provider assignments + canvas template) + TemplateTeamSelector + ProviderAssignments + ProviderRow
["src/shared/api/types.ts", 620], // ... + RespondToMode + respondTo/respondToAllowlist on ManagedAgent/Create/Update inputs
["src-tauri/src/events.rs", 610], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role + DM/presence/workflow command builders
Expand Down
98 changes: 86 additions & 12 deletions desktop/src/features/agents/channelAgents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import {
listManagedAgents,
startManagedAgent,
stopManagedAgent,
updateManagedAgent,
uploadMediaBytes,
} from "@/shared/api/tauri";
import type {
AcpProvider,
ChannelRole,
ManagedAgent,
ManagedAgentBackend,
RespondToMode,
} from "@/shared/api/types";

type ChannelAgentProvider = Pick<
Expand Down Expand Up @@ -56,11 +58,15 @@ export type CreateChannelManagedAgentInput = {
role?: Exclude<ChannelRole, "owner">;
ensureRunning?: boolean;
backend?: ManagedAgentBackend;
/** Inbound author gate mode. Omitted = server default ("owner-only"). */
respondTo?: RespondToMode;
/** Hex pubkeys for allowlist mode. */
respondToAllowlist?: string[];
};

export type CreateChannelManagedAgentResult =
AttachManagedAgentToChannelResult & {
created: true;
created: boolean;
providerId: string;
};

Expand Down Expand Up @@ -173,6 +179,19 @@ function pickPreferredManagedAgent(agents: ManagedAgent[]) {
})[0];
}

function findReusablePersonaAgent(
agents: ManagedAgent[],
personaId: string,
channelMemberPubkeys: ReadonlySet<string>,
): ManagedAgent | undefined {
const candidates = agents.filter(
(agent) =>
agent.personaId === personaId &&
!channelMemberPubkeys.has(normalizePubkey(agent.pubkey)),
);
return pickPreferredManagedAgent(candidates);
}

function buildChannelAgentName(providerId: string, providerLabel: string) {
const normalizedProviderId = providerId.trim().toLowerCase();
if (normalizedProviderId.length > 0) {
Expand Down Expand Up @@ -267,6 +286,10 @@ export async function ensureChannelAgentPresetInChannel(
export async function createChannelManagedAgent(
channelId: string,
input: CreateChannelManagedAgentInput,
context?: {
managedAgents?: ManagedAgent[];
channelMemberPubkeys?: ReadonlySet<string>;
},
): Promise<CreateChannelManagedAgentResult> {
const role = input.role ?? "bot";
const ensureRunning = input.ensureRunning ?? true;
Expand All @@ -276,6 +299,49 @@ export async function createChannelManagedAgent(
throw new Error("Agent name is required.");
}

// Smart reuse: if a managed agent with the same personaId already exists
// and is not already in this channel, attach it instead of creating a new one.
if (
input.personaId &&
context?.managedAgents &&
context.channelMemberPubkeys
) {
const reusable = findReusablePersonaAgent(
context.managedAgents,
input.personaId,
context.channelMemberPubkeys,
);
if (reusable) {
// Apply the caller's respondTo settings so the user's permission
// choice in the dialog is always honored, even when reusing.
const needsRespondToUpdate =
input.respondTo && input.respondTo !== "owner-only";
const updatedAgent = needsRespondToUpdate
? (
await updateManagedAgent({
pubkey: reusable.pubkey,
respondTo: input.respondTo,
respondToAllowlist:
input.respondTo === "allowlist"
? input.respondToAllowlist
: undefined,
})
).agent
: reusable;

const attached = await attachManagedAgentToChannel(channelId, {
agent: updatedAgent,
role,
ensureRunning,
});
return {
...attached,
created: false,
providerId: input.provider.id,
};
}
}

// If the avatar is a data URI (e.g. from a persona PNG card import),
// upload it to get a hosted URL the relay can serve.
let resolvedAvatarUrl = input.avatarUrl?.trim() || undefined;
Expand Down Expand Up @@ -307,6 +373,8 @@ export async function createChannelManagedAgent(
spawnAfterCreate: isProviderMode,
startOnAppLaunch: isProviderMode ? false : undefined,
backend: input.backend,
respondTo: input.respondTo,
respondToAllowlist: input.respondToAllowlist,
});

// Tauri returns Ok() even on deploy failure — spawnError carries the message.
Expand All @@ -331,27 +399,33 @@ export async function createChannelManagedAgents(
channelId: string,
inputs: readonly CreateChannelManagedAgentInput[],
): Promise<CreateChannelManagedAgentsResult> {
const results = await Promise.allSettled(
inputs.map((input) => createChannelManagedAgent(channelId, input)),
// Fetch managed agents and channel members once for smart reuse checks.
const [managedAgents, members] = await Promise.all([
listManagedAgents(),
getChannelMembers(channelId),
]);
const channelMemberPubkeys = new Set(
members.map((m) => normalizePubkey(m.pubkey)),
);
const context = { managedAgents, channelMemberPubkeys };

// Sequential loop: each agent must be fully created and its relay membership
// written before the next starts. Concurrent writes to the replaceable
// kind:39002 membership event cause last-write-wins data loss.
const successes: CreateChannelManagedAgentResult[] = [];
const failures: CreateChannelManagedAgentBatchFailure[] = [];

for (let i = 0; i < results.length; i++) {
const result = results[i];
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i];
if (result.status === "fulfilled") {
successes.push(result.value);
} else {
try {
const result = await createChannelManagedAgent(channelId, input, context);
successes.push(result);
} catch (error) {
failures.push({
kind: input.personaId ? "persona" : "generic",
name: input.name.trim() || "agent",
personaId: input.personaId ?? null,
error:
result.reason instanceof Error
? result.reason.message
: "Failed to add agent.",
error: error instanceof Error ? error.message : "Failed to add agent.",
});
}
}
Expand Down
68 changes: 68 additions & 0 deletions desktop/src/features/agents/ui/AgentGroupRows.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { ManagedAgent, PresenceLookup } from "@/shared/api/types";
import { ManagedAgentRow } from "./ManagedAgentRow";

export type AgentGroupRowsProps = {
agents: ManagedAgent[];
channelsByPubkey: Record<string, string[]>;
isActionPending: boolean;
logContent: string | null;
logError: Error | null;
logLoading: boolean;
personaLabelsById: Record<string, string>;
presenceLoaded: boolean;
presenceLookup: PresenceLookup;
selectedLogAgentPubkey: string | null;
onAddToChannel: (agent: ManagedAgent) => void;
onDelete: (pubkey: string) => void;
onSelectLogAgent: (pubkey: string | null) => void;
onStart: (pubkey: string) => void;
onStop: (pubkey: string) => void;
onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void;
};

export function AgentGroupRows({
agents,
channelsByPubkey,
isActionPending,
logContent,
logError,
logLoading,
personaLabelsById,
presenceLoaded,
presenceLookup,
selectedLogAgentPubkey,
onAddToChannel,
onDelete,
onSelectLogAgent,
onStart,
onStop,
onToggleStartOnAppLaunch,
}: AgentGroupRowsProps) {
return (
<div className="space-y-2 border-t border-border/50 px-3 py-2">
{agents.map((agent) => (
<ManagedAgentRow
agent={agent}
channelNames={channelsByPubkey[agent.pubkey] ?? []}
isActionPending={isActionPending}
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}
onAddToChannel={onAddToChannel}
onDelete={onDelete}
onSelectLogAgent={onSelectLogAgent}
onStart={onStart}
onStop={onStop}
onToggleStartOnAppLaunch={onToggleStartOnAppLaunch}
/>
))}
</div>
);
}
Loading
Loading