feat(desktop): persona-first agent UX + bug fixes (#570)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-05-13 15:13:41 -07:00
committed by GitHub
parent 9a403d3e8a
commit efbdc7bfd5
17 changed files with 1382 additions and 496 deletions

View File

@@ -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

View File

@@ -6,6 +6,7 @@ import {
listManagedAgents,
startManagedAgent,
stopManagedAgent,
updateManagedAgent,
uploadMediaBytes,
} from "@/shared/api/tauri";
import type {
@@ -13,6 +14,7 @@ import type {
ChannelRole,
ManagedAgent,
ManagedAgentBackend,
RespondToMode,
} from "@/shared/api/types";
type ChannelAgentProvider = Pick<
@@ -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;
};
@@ -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) {
@@ -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;
@@ -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;
@@ -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.
@@ -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.",
});
}
}

View File

@@ -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>
);
}

View File

@@ -2,12 +2,10 @@ import { AddAgentToChannelDialog } from "./AddAgentToChannelDialog";
import { AddTeamToChannelDialog } from "./AddTeamToChannelDialog";
import { BatchImportDialog } from "./BatchImportDialog";
import { CreateAgentDialog } from "./CreateAgentDialog";
import { ManagedAgentsSection } from "./ManagedAgentsSection";
import { PersonaCatalogDialog } from "./PersonaCatalogDialog";
import { PersonaDialog } from "./PersonaDialog";
import { PersonaDeleteDialog } from "./PersonaDeleteDialog";
import { PersonaImportUpdateDialog } from "./PersonaImportUpdateDialog";
import { PersonasSection } from "./PersonasSection";
import { RelayDirectorySection } from "./RelayDirectorySection";
import { SecretRevealDialog } from "./SecretRevealDialog";
import { TeamDeleteDialog } from "./TeamDeleteDialog";
@@ -15,6 +13,7 @@ import { TeamDialog } from "./TeamDialog";
import { TeamImportDialog } from "./TeamImportDialog";
import { TeamImportUpdateDialog } from "./TeamImportUpdateDialog";
import { TeamsSection } from "./TeamsSection";
import { UnifiedAgentsSection } from "./UnifiedAgentsSection";
import { useManagedAgentActions } from "./useManagedAgentActions";
import { usePersonaActions } from "./usePersonaActions";
import { useTeamActions } from "./useTeamActions";
@@ -46,38 +45,91 @@ export function AgentsView() {
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 pb-4 pt-14 sm:px-6">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<div className="flex flex-col gap-6">
<PersonasSection
<UnifiedAgentsSection
actionErrorMessage={agents.actionErrorMessage}
actionNoticeMessage={agents.actionNoticeMessage}
agents={agents.managedAgents}
channelsByPubkey={agents.channelsByPubkey}
agentsError={
agents.managedAgentsQuery.error instanceof Error
? agents.managedAgentsQuery.error
: null
}
isActionPending={isActionPending}
isAgentsLoading={agents.managedAgentsQuery.isLoading}
logContent={agents.managedAgentLogQuery.data?.content ?? null}
logError={
agents.managedAgentLogQuery.error instanceof Error
? agents.managedAgentLogQuery.error
: null
}
logLoading={agents.managedAgentLogQuery.isLoading}
personaLabelsById={personas.personaLabelsById}
presenceLoaded={agents.managedPresenceQuery.isSuccess}
presenceLookup={agents.managedPresenceQuery.data ?? {}}
onAddToChannel={(agent) => {
agents.setActionNoticeMessage(null);
agents.setActionErrorMessage(null);
agents.setAgentToAddToChannel(agent);
}}
onBulkRemoveStopped={() => {
void agents.handleBulkRemoveStopped();
}}
onBulkStopRunning={() => {
void agents.handleBulkStopRunning();
}}
onCreateAgent={() => {
agents.setIsCreateOpen(true);
}}
onDeleteAgent={(pubkey) => {
void agents.handleDelete(pubkey);
}}
onSelectLogAgent={agents.setLogAgentPubkey}
onStartAgent={(pubkey) => {
void agents.handleStart(pubkey);
}}
onStopAgent={(pubkey) => {
void agents.handleStop(pubkey);
}}
onToggleStartOnAppLaunch={(pubkey, startOnAppLaunch) => {
void agents.handleToggleStartOnAppLaunch(
pubkey,
startOnAppLaunch,
);
}}
selectedLogAgentPubkey={agents.logAgentPubkey}
// Persona props
canChooseCatalog={personas.catalogPersonas.length > 0}
error={
personas={personas.personasQuery.data ?? []}
personasError={
personas.personasQuery.error instanceof Error
? personas.personasQuery.error
: null
}
feedbackErrorMessage={
personaFeedbackErrorMessage={
personas.personaFeedbackSurface === "library"
? personas.personaErrorMessage
: null
}
feedbackNoticeMessage={
personaFeedbackNoticeMessage={
personas.personaFeedbackSurface === "library"
? personas.personaNoticeMessage
: null
}
isLoading={personas.personasQuery.isLoading}
isPending={personas.isPending}
isPersonasLoading={personas.personasQuery.isLoading}
isPersonasPending={personas.isPending}
onCreatePersona={personas.openCreate}
onChooseCatalog={personas.openCatalog}
onCreate={personas.openCreate}
onDelete={personas.openDelete}
onDeactivate={(persona) => {
onDuplicatePersona={personas.openDuplicate}
onEditPersona={personas.openEdit}
onExportPersona={personas.handleExport}
onDeactivatePersona={(persona) => {
void personas.handleSetActive(persona, false, "library");
}}
onDuplicate={personas.openDuplicate}
onEdit={personas.openEdit}
onImportFile={(fileBytes, fileName) => {
onDeletePersona={personas.openDelete}
onImportPersonaFile={(fileBytes, fileName) => {
void personas.handleImportFile(fileBytes, fileName);
}}
onExport={personas.handleExport}
personas={personas.libraryPersonas}
/>
<TeamsSection
@@ -103,61 +155,6 @@ export function AgentsView() {
teams={teamActions.teams}
/>
<ManagedAgentsSection
actionErrorMessage={agents.actionErrorMessage}
actionNoticeMessage={agents.actionNoticeMessage}
agents={agents.managedAgents}
channelsByPubkey={agents.channelsByPubkey}
error={
agents.managedAgentsQuery.error instanceof Error
? agents.managedAgentsQuery.error
: null
}
isActionPending={isActionPending}
isLoading={agents.managedAgentsQuery.isLoading}
logContent={agents.managedAgentLogQuery.data?.content ?? null}
logError={
agents.managedAgentLogQuery.error instanceof Error
? agents.managedAgentLogQuery.error
: null
}
logLoading={agents.managedAgentLogQuery.isLoading}
personaLabelsById={personas.personaLabelsById}
presenceLoaded={agents.managedPresenceQuery.isSuccess}
presenceLookup={agents.managedPresenceQuery.data ?? {}}
onAddToChannel={(agent) => {
agents.setActionNoticeMessage(null);
agents.setActionErrorMessage(null);
agents.setAgentToAddToChannel(agent);
}}
onBulkRemoveStopped={() => {
void agents.handleBulkRemoveStopped();
}}
onBulkStopRunning={() => {
void agents.handleBulkStopRunning();
}}
onCreate={() => {
agents.setIsCreateOpen(true);
}}
onDelete={(pubkey) => {
void agents.handleDelete(pubkey);
}}
onSelectLogAgent={agents.setLogAgentPubkey}
onStart={(pubkey) => {
void agents.handleStart(pubkey);
}}
onStop={(pubkey) => {
void agents.handleStop(pubkey);
}}
onToggleStartOnAppLaunch={(pubkey, startOnAppLaunch) => {
void agents.handleToggleStartOnAppLaunch(
pubkey,
startOnAppLaunch,
);
}}
selectedLogAgentPubkey={agents.logAgentPubkey}
/>
<RelayDirectorySection
error={
agents.relayAgentsQuery.error instanceof Error

View File

@@ -1,4 +1,11 @@
import { Ellipsis, OctagonX, Trash2 } from "lucide-react";
import * as React from "react";
import {
ChevronDown,
ChevronRight,
Ellipsis,
OctagonX,
Trash2,
} from "lucide-react";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
@@ -15,6 +22,61 @@ import { Skeleton } from "@/shared/ui/skeleton";
import { CreateNewButton } from "./CreateNewButton";
import { ManagedAgentRow } from "./ManagedAgentRow";
type PersonaGroup = {
key: string;
label: string;
agents: ManagedAgent[];
};
function groupAgentsByPersona(
agents: ManagedAgent[],
personaLabelsById: Record<string, string>,
): PersonaGroup[] {
const grouped = new Map<string, ManagedAgent[]>();
const ungrouped: ManagedAgent[] = [];
const unknownPersona: ManagedAgent[] = [];
for (const agent of agents) {
if (!agent.personaId) {
ungrouped.push(agent);
} else if (personaLabelsById[agent.personaId]) {
const existing = grouped.get(agent.personaId) ?? [];
existing.push(agent);
grouped.set(agent.personaId, existing);
} else {
unknownPersona.push(agent);
}
}
const groups: PersonaGroup[] = [];
for (const [personaId, groupAgents] of grouped) {
groups.push({
key: personaId,
label: personaLabelsById[personaId],
agents: groupAgents,
});
}
if (unknownPersona.length > 0) {
groups.push({
key: "__unknown__",
label: "Unknown Persona",
agents: unknownPersona,
});
}
if (ungrouped.length > 0) {
groups.push({
key: "__ungrouped__",
label: "Custom Agents",
agents: ungrouped,
});
}
return groups;
}
export function ManagedAgentsSection({
actionErrorMessage,
actionNoticeMessage,
@@ -69,6 +131,27 @@ export function ManagedAgentsSection({
(a) => a.status === "stopped" || a.status === "not_deployed",
).length;
const groups = React.useMemo(
() => groupAgentsByPersona(agents, personaLabelsById),
[agents, personaLabelsById],
);
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(
new Set(),
);
function toggleGroup(key: string) {
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}
useFeedbackToasts(actionNoticeMessage, actionErrorMessage);
return (
@@ -154,32 +237,64 @@ export function ManagedAgentsSection({
) : null}
{!isLoading && agents.length > 0 ? (
<div className="space-y-2" data-testid="managed-agents-table">
{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 className="space-y-3" data-testid="managed-agents-table">
{groups.map((group) => {
const isCollapsed = collapsedGroups.has(group.key);
return (
<div key={group.key} className="space-y-2">
<button
className="flex w-full items-center gap-2 rounded-lg px-1 py-1 text-left transition-colors hover:bg-muted/40"
onClick={() => toggleGroup(group.key)}
type="button"
>
{isCollapsed ? (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="text-sm font-medium">{group.label}</span>
<span className="text-xs text-muted-foreground">
({group.agents.length})
</span>
</button>
{!isCollapsed ? (
<div className="space-y-2">
{group.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>
) : null}
</div>
);
})}
</div>
) : null}

View File

@@ -0,0 +1,93 @@
import { CopyPlus, Download, 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,
onExport,
onDeactivate,
onDelete,
}: {
isActionPending: boolean;
isPending: boolean;
persona: AgentPersona;
onDuplicate: (persona: AgentPersona) => void;
onEdit: (persona: AgentPersona) => void;
onExport: (persona: AgentPersona) => void;
onDeactivate: (persona: AgentPersona) => void;
onDelete: (persona: AgentPersona) => void;
}) {
const disabled = isActionPending || isPending;
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()}
>
{!persona.isBuiltIn ? (
<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>
<DropdownMenuItem disabled={disabled} onClick={() => onExport(persona)}>
<Download className="h-4 w-4" />
Export
</DropdownMenuItem>
<DropdownMenuSeparator />
{persona.isBuiltIn ? (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={disabled}
onClick={() => onDeactivate(persona)}
>
<Trash2 className="h-4 w-4" />
Remove from My Agents
</DropdownMenuItem>
) : persona.sourcePack ? (
<DropdownMenuItem disabled>
<Trash2 className="h-4 w-4" />
Managed by pack
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={disabled}
onClick={() => onDelete(persona)}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,247 +0,0 @@
import {
CopyPlus,
Download,
Ellipsis,
Pencil,
Trash2,
Upload,
} from "lucide-react";
import type { AgentPersona } from "@/shared/api/types";
import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
import { Card } from "@/shared/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Skeleton } from "@/shared/ui/skeleton";
import { PersonaIdentity } from "./PersonaIdentity";
import { PersonaLibraryEntryPoints } from "./PersonaLibraryEntryPoints";
import { personaLibraryCopy } from "./personaLibraryCopy";
type PersonasSectionProps = {
canChooseCatalog: boolean;
personas: AgentPersona[];
error: Error | null;
feedbackErrorMessage: string | null;
feedbackNoticeMessage: string | null;
isLoading: boolean;
isPending: boolean;
onCreate: () => void;
onChooseCatalog: () => void;
onDuplicate: (persona: AgentPersona) => void;
onEdit: (persona: AgentPersona) => void;
onExport: (persona: AgentPersona) => void;
onDeactivate: (persona: AgentPersona) => void;
onDelete: (persona: AgentPersona) => void;
onImportFile: (fileBytes: number[], fileName: string) => void;
};
export function PersonasSection({
canChooseCatalog,
personas,
error,
feedbackErrorMessage,
feedbackNoticeMessage,
isLoading,
isPending,
onCreate,
onChooseCatalog,
onDuplicate,
onEdit,
onExport,
onDeactivate,
onDelete,
onImportFile,
}: PersonasSectionProps) {
const {
fileInputRef,
isDragOver,
dropHandlers,
handleFileChange,
openFilePicker,
} = useFileImportZone({ onImportFile });
useFeedbackToasts(feedbackNoticeMessage, feedbackErrorMessage);
return (
<section
className="relative space-y-4"
data-testid="agents-library-personas"
{...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 .persona.md, .persona.json, .persona.png, or .zip to import
</p>
</div>
) : null}
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold tracking-tight">
{personaLibraryCopy.title}
</h3>
<p className="text-sm text-muted-foreground">
{personaLibraryCopy.description}
</p>
</div>
<input
accept=".md,.json,.png,.zip"
className="hidden"
onChange={handleFileChange}
ref={fileInputRef}
type="file"
/>
<div className="flex items-center gap-2">
<PersonaLibraryEntryPoints
canChooseCatalog={canChooseCatalog && personas.length > 0}
isPending={isPending}
layout="header"
onCreate={onCreate}
onChooseCatalog={onChooseCatalog}
/>
</div>
</div>
{isLoading ? (
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
{["first", "second", "third", "fourth"].map((key) => (
<Card className="p-2" 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-14 rounded-full" />
</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) => (
<Card
className="overflow-hidden p-2"
data-testid={`library-persona-${persona.id}`}
key={persona.id}
>
<div className="flex items-start justify-between gap-2.5">
<PersonaIdentity persona={persona} />
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
aria-label={`Open actions for ${persona.displayName}`}
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
data-testid={`library-persona-menu-${persona.id}`}
type="button"
>
<Ellipsis className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
{!persona.isBuiltIn ? (
<DropdownMenuItem
disabled={isPending}
onClick={() => onEdit(persona)}
>
<Pencil className="h-4 w-4" />
Edit
</DropdownMenuItem>
) : null}
<DropdownMenuItem
disabled={isPending}
onClick={() => onDuplicate(persona)}
>
<CopyPlus className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
disabled={isPending}
onClick={() => onExport(persona)}
>
<Download className="h-4 w-4" />
Export
</DropdownMenuItem>
{persona.isBuiltIn ? (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={isPending}
onClick={() => onDeactivate(persona)}
>
<Trash2 className="h-4 w-4" />
Remove from My Agents
</DropdownMenuItem>
) : persona.sourcePack ? (
<DropdownMenuItem disabled>
<Trash2 className="h-4 w-4" />
Managed by pack
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={isPending}
onClick={() => onDelete(persona)}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</Card>
))}
<button
className="flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-dashed border-primary p-2 text-primary transition-colors hover:bg-primary/5"
onClick={openFilePicker}
type="button"
>
<Upload className="h-4 w-4" />
<span className="text-xs">Import</span>
</button>
</div>
) : null}
{!isLoading && personas.length === 0 ? (
<div className="rounded-xl border border-dashed border-primary/40 px-6 py-10 text-center">
<p className="text-sm font-semibold tracking-tight">
{personaLibraryCopy.emptyTitle}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{personaLibraryCopy.emptyDescription}
</p>
<p className="mt-1 text-xs text-muted-foreground/70">
{personaLibraryCopy.emptyImportHint}
</p>
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
<PersonaLibraryEntryPoints
canChooseCatalog={canChooseCatalog}
isPending={isPending}
layout="empty"
onCreate={onCreate}
onChooseCatalog={onChooseCatalog}
onImport={openFilePicker}
/>
</div>
</div>
) : null}
{error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error.message}
</p>
) : null}
</section>
);
}

View File

@@ -0,0 +1,561 @@
import * as React from "react";
import {
ChevronDown,
ChevronRight,
Ellipsis,
OctagonX,
Plus,
Trash2,
} from "lucide-react";
import { isPersonaActive } from "@/features/agents/lib/catalog";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
import type {
AgentPersona,
ManagedAgent,
PresenceLookup,
} from "@/shared/api/types";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Skeleton } from "@/shared/ui/skeleton";
import { AgentGroupRows } from "./AgentGroupRows";
import { PersonaActionsMenu } from "./PersonaActionsMenu";
import { PersonaIdentity } from "./PersonaIdentity";
import { PersonaLibraryEntryPoints } from "./PersonaLibraryEntryPoints";
type UnifiedAgentsSectionProps = {
actionErrorMessage: string | null;
actionNoticeMessage: string | null;
agents: ManagedAgent[];
channelsByPubkey: Record<string, string[]>;
agentsError: Error | null;
isActionPending: boolean;
isAgentsLoading: boolean;
logContent: string | null;
logError: Error | null;
logLoading: boolean;
personaLabelsById: Record<string, string>;
presenceLoaded: boolean;
presenceLookup: PresenceLookup;
onAddToChannel: (agent: ManagedAgent) => void;
onBulkRemoveStopped: () => void;
onBulkStopRunning: () => void;
onCreateAgent: () => void;
onDeleteAgent: (pubkey: string) => void;
onSelectLogAgent: (pubkey: string | null) => void;
onStartAgent: (pubkey: string) => void;
onStopAgent: (pubkey: string) => void;
onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void;
selectedLogAgentPubkey: string | null;
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;
onExportPersona: (persona: AgentPersona) => void;
onDeactivatePersona: (persona: AgentPersona) => void;
onDeletePersona: (persona: AgentPersona) => void;
onImportPersonaFile: (fileBytes: number[], fileName: string) => void;
};
type PersonaGroup = { persona: AgentPersona; agents: ManagedAgent[] };
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((p) => {
matched.add(p.id);
return { persona: p, agents: byPersonaId.get(p.id) ?? [] };
});
const unknown: ManagedAgent[] = [];
for (const [id, list] of byPersonaId) {
if (!matched.has(id)) unknown.push(...list);
}
return { groups, ungrouped, unknown };
}
export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
const {
actionErrorMessage,
actionNoticeMessage,
agents,
channelsByPubkey,
agentsError,
isActionPending,
isAgentsLoading,
logContent,
logError,
logLoading,
personaLabelsById,
presenceLoaded,
presenceLookup,
onAddToChannel,
onBulkRemoveStopped,
onBulkStopRunning,
onCreateAgent,
onDeleteAgent,
onSelectLogAgent,
onStartAgent,
onStopAgent,
onToggleStartOnAppLaunch,
selectedLogAgentPubkey,
canChooseCatalog,
personas,
personasError,
personaFeedbackErrorMessage,
personaFeedbackNoticeMessage,
isPersonasLoading,
isPersonasPending,
onCreatePersona,
onChooseCatalog,
onDuplicatePersona,
onEditPersona,
onExportPersona,
onDeactivatePersona,
onDeletePersona,
onImportPersonaFile,
} = props;
const runningCount = agents.filter((a) => isManagedAgentActive(a)).length;
const stoppedCount = agents.filter(
(a) => a.status === "stopped" || a.status === "not_deployed",
).length;
const { groups, ungrouped, unknown } = React.useMemo(
() => buildUnifiedGroups(personas, agents),
[personas, agents],
);
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;
});
}
useFeedbackToasts(actionNoticeMessage, actionErrorMessage);
useFeedbackToasts(personaFeedbackNoticeMessage, personaFeedbackErrorMessage);
const isLoading = isAgentsLoading || isPersonasLoading;
const rowProps = {
channelsByPubkey,
isActionPending,
logContent,
logError,
logLoading,
personaLabelsById,
presenceLoaded,
presenceLookup,
selectedLogAgentPubkey,
onAddToChannel,
onDelete: onDeleteAgent,
onSelectLogAgent,
onStart: onStartAgent,
onStop: onStopAgent,
onToggleStartOnAppLaunch,
} as const;
return (
<section
className="relative space-y-4"
data-testid="agents-library-personas"
{...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 .persona.md, .persona.json, .persona.png, or .zip to import
</p>
</div>
) : null}
<SectionHeader
agentCount={agents.length}
canChooseCatalog={canChooseCatalog}
fileInputRef={fileInputRef}
handleFileChange={handleFileChange}
isActionPending={isActionPending}
isPersonasPending={isPersonasPending}
openFilePicker={openFilePicker}
runningCount={runningCount}
stoppedCount={stoppedCount}
onBulkRemoveStopped={onBulkRemoveStopped}
onBulkStopRunning={onBulkStopRunning}
onChooseCatalog={onChooseCatalog}
onCreateAgent={onCreateAgent}
onCreatePersona={onCreatePersona}
/>
{isLoading ? <LoadingSkeleton /> : null}
{!isLoading && personas.length === 0 && agents.length === 0 ? (
<EmptyState
canChooseCatalog={canChooseCatalog}
isPersonasPending={isPersonasPending}
openFilePicker={openFilePicker}
onChooseCatalog={onChooseCatalog}
onCreatePersona={onCreatePersona}
/>
) : null}
{!isLoading && (personas.length > 0 || agents.length > 0) ? (
<div className="space-y-3" data-testid="unified-agents-groups">
{groups.map((g) => {
const isCollapsed = collapsed.has(g.persona.id);
const hasAgents = g.agents.length > 0;
const isDeactivated = !isPersonaActive(g.persona);
return (
<div
key={g.persona.id}
className={`rounded-xl border border-border/70 bg-card/40${isDeactivated ? " opacity-60" : ""}`}
>
<div className="flex items-center gap-2 px-3 py-2">
<button
className="flex min-w-0 flex-1 items-center gap-2 rounded-lg py-1 text-left transition-colors hover:bg-muted/40"
onClick={() => toggle(g.persona.id)}
type="button"
>
{isCollapsed ? (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<PersonaIdentity
persona={g.persona}
showPromptTooltip={false}
/>
<span className="shrink-0 text-xs text-muted-foreground">
{hasAgents
? `${g.agents.length} instance${g.agents.length === 1 ? "" : "s"}`
: "Not deployed"}
</span>
</button>
<div className="flex shrink-0 items-center gap-1">
{isDeactivated ? (
<Badge variant="outline">Deactivated</Badge>
) : !hasAgents ? (
<Badge variant="outline">Inactive</Badge>
) : null}
<PersonaActionsMenu
isActionPending={isActionPending}
isPending={isPersonasPending}
persona={g.persona}
onDuplicate={onDuplicatePersona}
onEdit={onEditPersona}
onExport={onExportPersona}
onDeactivate={onDeactivatePersona}
onDelete={onDeletePersona}
/>
</div>
</div>
{!isCollapsed && hasAgents ? (
<AgentGroupRows agents={g.agents} {...rowProps} />
) : null}
</div>
);
})}
{unknown.length > 0 ? (
<CollapsibleAgentGroup
agents={unknown}
collapsed={collapsed}
groupKey="__unknown__"
label="Unknown Persona"
rowProps={rowProps}
onToggle={toggle}
/>
) : null}
{ungrouped.length > 0 ? (
<CollapsibleAgentGroup
agents={ungrouped}
collapsed={collapsed}
groupKey="__ungrouped__"
label="Custom Agents"
rowProps={rowProps}
onToggle={toggle}
/>
) : null}
</div>
) : null}
{!isLoading && stoppedCount > 0 ? (
<div className="flex items-center justify-between rounded-xl border border-border/60 bg-muted/30 px-4 py-2.5">
<p className="text-sm text-muted-foreground">
{stoppedCount} stopped {stoppedCount === 1 ? "agent" : "agents"}
</p>
<Button
className="text-destructive"
disabled={isActionPending}
onClick={onBulkRemoveStopped}
size="sm"
variant="ghost"
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Remove stopped
</Button>
</div>
) : null}
{agentsError ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{agentsError.message}
</p>
) : null}
{personasError ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{personasError.message}
</p>
) : null}
</section>
);
}
function SectionHeader({
agentCount,
canChooseCatalog,
fileInputRef,
handleFileChange,
isActionPending,
isPersonasPending,
openFilePicker,
runningCount,
stoppedCount,
onBulkRemoveStopped,
onBulkStopRunning,
onChooseCatalog,
onCreateAgent,
onCreatePersona,
}: {
agentCount: number;
canChooseCatalog: boolean;
fileInputRef: React.RefObject<HTMLInputElement | null>;
handleFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
isActionPending: boolean;
isPersonasPending: boolean;
openFilePicker: () => void;
runningCount: number;
stoppedCount: number;
onBulkRemoveStopped: () => void;
onBulkStopRunning: () => void;
onChooseCatalog: () => void;
onCreateAgent: () => void;
onCreatePersona: () => void;
}) {
return (
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold tracking-tight">Your Agents</h3>
<p className="text-sm text-muted-foreground">
Personas and their deployed agent instances.
</p>
</div>
<input
accept=".md,.json,.png,.zip"
className="hidden"
onChange={handleFileChange}
ref={fileInputRef}
type="file"
/>
<div className="flex items-center gap-2">
{agentCount > 0 ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label="Bulk actions"
className="h-7 w-7"
size="icon"
variant="ghost"
>
<Ellipsis className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem
disabled={isActionPending || runningCount === 0}
onClick={onBulkStopRunning}
>
<OctagonX className="h-4 w-4" />
Stop all running ({runningCount})
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={isActionPending || stoppedCount === 0}
onClick={onBulkRemoveStopped}
>
<Trash2 className="h-4 w-4" />
Remove all stopped ({stoppedCount})
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size="sm" type="button" variant="default">
<Plus className="h-4 w-4" />
New
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem
disabled={isPersonasPending}
onClick={onCreatePersona}
>
Persona
</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>
</div>
</div>
);
}
function LoadingSkeleton() {
return (
<Card className="overflow-hidden">
{["a", "b", "c"].map((k) => (
<div
className="flex items-center gap-4 border-b border-border/60 px-4 py-3 last:border-b-0"
key={k}
>
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-4 w-28" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</Card>
);
}
function EmptyState({
canChooseCatalog,
isPersonasPending,
openFilePicker,
onChooseCatalog,
onCreatePersona,
}: {
canChooseCatalog: boolean;
isPersonasPending: boolean;
openFilePicker: () => void;
onChooseCatalog: () => void;
onCreatePersona: () => void;
}) {
return (
<div className="rounded-xl border border-dashed border-primary/40 px-6 py-10 text-center">
<p className="text-sm font-semibold tracking-tight">No agents yet</p>
<p className="mt-2 text-sm text-muted-foreground">
Create a persona or choose one from the catalog, then deploy it to a
channel.
</p>
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
<PersonaLibraryEntryPoints
canChooseCatalog={canChooseCatalog}
isPending={isPersonasPending}
layout="empty"
onCreate={onCreatePersona}
onChooseCatalog={onChooseCatalog}
onImport={openFilePicker}
/>
</div>
</div>
);
}
function CollapsibleAgentGroup({
groupKey,
label,
agents,
collapsed,
onToggle,
rowProps,
}: {
groupKey: string;
label: string;
agents: ManagedAgent[];
collapsed: ReadonlySet<string>;
onToggle: (key: string) => void;
rowProps: Omit<React.ComponentProps<typeof AgentGroupRows>, "agents">;
}) {
const isCollapsed = collapsed.has(groupKey);
return (
<div className="rounded-xl border border-border/70 bg-card/40">
<div className="px-3 py-2">
<button
className="flex w-full items-center gap-2 rounded-lg py-1 text-left transition-colors hover:bg-muted/40"
onClick={() => onToggle(groupKey)}
type="button"
>
{isCollapsed ? (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<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>
</div>
{!isCollapsed ? <AgentGroupRows agents={agents} {...rowProps} /> : null}
</div>
);
}

View File

@@ -17,6 +17,7 @@ import type {
BackendProviderCandidate,
BackendProviderProbeResult,
ManagedAgentBackend,
RespondToMode,
} from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Dialog } from "@/shared/ui/dialog";
@@ -39,6 +40,7 @@ import {
import { getActivePersonas } from "@/features/agents/lib/catalog";
import { getUsableTeams } from "@/features/agents/lib/teamPersonas";
import { useLastRuntimeProvider } from "@/features/agents/lib/useLastRuntimeProvider";
import { CreateAgentRespondToField } from "@/features/agents/ui/RespondToField";
type AddChannelBotDialogProps = {
backendProviders?: BackendProviderCandidate[];
@@ -129,6 +131,10 @@ export function AddChannelBotDialog({
const [submissionError, setSubmissionError] = React.useState<string | null>(
null,
);
const [respondTo, setRespondTo] = React.useState<RespondToMode>("owner-only");
const [respondToAllowlist, setRespondToAllowlist] = React.useState<string[]>(
[],
);
const resolvedBackendProviders = backendProviders ?? [];
const resolvedBackendProvidersLoading = backendProvidersLoading ?? false;
@@ -264,6 +270,8 @@ export function AddChannelBotDialog({
setProviderConfig({});
setProbedProvider(null);
setProbeError(null);
setRespondTo("owner-only");
setRespondToAllowlist([]);
createBotsMutation.reset();
}
@@ -313,6 +321,15 @@ export function AddChannelBotDialog({
}
: { type: "local" };
const respondToFields =
respondTo !== "owner-only"
? {
respondTo,
respondToAllowlist:
respondTo === "allowlist" ? respondToAllowlist : undefined,
}
: {};
const inputs = [
...(includeGeneric
? [
@@ -322,6 +339,7 @@ export function AddChannelBotDialog({
systemPrompt: customPrompt,
role: "bot" as const,
backend,
...respondToFields,
},
]
: []),
@@ -340,6 +358,7 @@ export function AddChannelBotDialog({
model: persona.model ?? undefined,
role: "bot" as const,
backend,
...respondToFields,
};
}),
];
@@ -384,10 +403,17 @@ export function AddChannelBotDialog({
}
}
// Allowlist mode requires at least one entry, mirroring the harness's own
// validation. If we let it through empty, the agent crash-loops at startup
// with a config error.
const respondToValid =
respondTo !== "allowlist" || respondToAllowlist.length > 0;
const canSubmit =
selectedProvider !== null &&
selectedCount > 0 &&
(!includeGeneric || customName.trim().length > 0) &&
respondToValid &&
!(isProviderMode && !probedProvider) &&
providerConfigComplete &&
!providersLoading &&
@@ -568,6 +594,16 @@ export function AddChannelBotDialog({
/>
) : null}
{selectedCount > 0 ? (
<CreateAgentRespondToField
allowlist={respondToAllowlist}
disabled={createBotsMutation.isPending}
mode={respondTo}
onAllowlistChange={setRespondToAllowlist}
onModeChange={setRespondTo}
/>
) : null}
{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.

View File

@@ -45,6 +45,7 @@ import type {
Identity,
Profile,
RelayEvent,
RespondToMode,
} from "@/shared/api/types";
import { useChannelFind } from "@/features/search/useChannelFind";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
@@ -112,7 +113,6 @@ export function ChannelScreen({
markChannelRead(activeChannelId, activeReadAt);
}, [activeChannel?.isMember, activeChannelId, activeReadAt, markChannelRead]);
const {
activeChannelTitle,
activeDmPresenceStatus,
@@ -218,20 +218,20 @@ export function ChannelScreen({
const channelMembersQuery = useChannelMembersQuery(activeChannel?.id ?? null);
const channelMembers = channelMembersQuery.data;
const personasQuery = usePersonasQuery();
const personaLookup = React.useMemo(() => {
const { personaLookup, respondToLookup } = React.useMemo(() => {
const agents = managedAgentsQuery.data ?? [];
const personas = personasQuery.data ?? [];
const personaById = new Map(personas.map((p) => [p.id, p.displayName]));
const lookup = new Map<string, string>();
const personaById = new Map(
(personasQuery.data ?? []).map((p) => [p.id, p.displayName]),
);
const pLookup = new Map<string, string>();
const rLookup = new Map<string, RespondToMode>();
for (const agent of agents) {
if (agent.personaId) {
const personaName = personaById.get(agent.personaId);
if (personaName) {
lookup.set(agent.pubkey.toLowerCase(), personaName);
}
}
const key = agent.pubkey.toLowerCase();
rLookup.set(key, agent.respondTo);
const pName = agent.personaId ? personaById.get(agent.personaId) : null;
if (pName) pLookup.set(key, pName);
}
return lookup;
return { personaLookup: pLookup, respondToLookup: rLookup };
}, [managedAgentsQuery.data, personasQuery.data]);
const timelineMessages = React.useMemo(
() =>
@@ -243,6 +243,7 @@ export function ChannelScreen({
messageProfiles,
channelMembers,
personaLookup,
respondToLookup,
),
[
activeChannel,
@@ -251,6 +252,7 @@ export function ChannelScreen({
currentPubkey,
messageProfiles,
personaLookup,
respondToLookup,
resolvedMessages,
],
);
@@ -258,7 +260,6 @@ export function ChannelScreen({
channelId: activeChannelId,
messages: timelineMessages,
});
const directReplyIdsByParentId = React.useMemo(() => {
const map = new Map<string, string[]>();
for (const message of timelineMessages) {

View File

@@ -4,6 +4,8 @@ import {
useAddChannelMembersMutation,
useChannelMembersQuery,
} from "@/features/channels/hooks";
import { useUpdateManagedAgentMutation } from "@/features/agents/hooks";
import { CreateAgentRespondToField } from "@/features/agents/ui/RespondToField";
import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers";
import {
formatMemberName,
@@ -12,7 +14,20 @@ import {
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { usePresenceQuery } from "@/features/presence/hooks";
import { changeChannelMemberRole } from "@/shared/api/tauri";
import type { Channel, ChannelMember } from "@/shared/api/types";
import type {
Channel,
ChannelMember,
ManagedAgent,
RespondToMode,
} from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
import { normalizePubkey } from "@/shared/lib/pubkey";
import {
@@ -146,6 +161,9 @@ export function MembersSidebar({
useFeedbackToasts(actionNoticeMessage, actionErrorMessage);
const [editRespondToAgent, setEditRespondToAgent] =
React.useState<ManagedAgent | null>(null);
if (!channel) {
return null;
}
@@ -170,6 +188,7 @@ export function MembersSidebar({
onChangeRole={(m, role) => {
void changeRoleMutation.mutateAsync({ pubkey: m.pubkey, role });
}}
onEditRespondTo={memberIsBot ? setEditRespondToAgent : undefined}
onManagedAgentAction={(agent) => {
void handleAgentLifecycleAction(agent);
}}
@@ -194,102 +213,195 @@ export function MembersSidebar({
}
return (
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetContent
className="flex w-full flex-col gap-0 overflow-hidden border-l border-border/80 bg-background p-0 sm:max-w-md"
data-testid="members-sidebar"
side="right"
>
<SheetHeader className="relative z-10 space-y-2 bg-background/25 px-6 py-6 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)]">
<SheetTitle>Members</SheetTitle>
<SheetDescription>
People and bots in {channel.name}.
</SheetDescription>
</SheetHeader>
<>
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetContent
className="flex w-full flex-col gap-0 overflow-hidden border-l border-border/80 bg-background p-0 sm:max-w-md"
data-testid="members-sidebar"
side="right"
>
<SheetHeader className="relative z-10 space-y-2 bg-background/25 px-6 py-6 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)]">
<SheetTitle>Members</SheetTitle>
<SheetDescription>
People and bots in {channel.name}.
</SheetDescription>
</SheetHeader>
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-6">
{(canManageMembers || channel.visibility === "open") &&
channel.channelType !== "dm" ? (
<ChannelMemberInviteCard
existingMembers={rawMembers}
isPending={addMembersMutation.isPending}
onSubmit={(input) => addMembersMutation.mutateAsync(input)}
open={open}
requestErrorMessage={
addMembersMutation.error instanceof Error
? addMembersMutation.error.message
: null
}
/>
) : null}
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-6">
{(canManageMembers || channel.visibility === "open") &&
channel.channelType !== "dm" ? (
<ChannelMemberInviteCard
existingMembers={rawMembers}
isPending={addMembersMutation.isPending}
onSubmit={(input) => addMembersMutation.mutateAsync(input)}
open={open}
requestErrorMessage={
addMembersMutation.error instanceof Error
? addMembersMutation.error.message
: null
}
/>
) : null}
<section className="space-y-2.5">
<div className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold tracking-tight">People</h2>
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{people.length}
</span>
</div>
<div className="space-y-2" data-testid="members-sidebar-people">
{people.length > 0 ? (
people.map((member) => renderMemberCard(member, false))
) : (
<p className="text-sm text-muted-foreground">
{membersQuery.isLoading
? "Loading members..."
: "No people found."}
</p>
)}
</div>
</section>
<section className="space-y-2.5">
<div className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold tracking-tight">People</h2>
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{people.length}
</span>
</div>
<div className="space-y-2" data-testid="members-sidebar-people">
{people.length > 0 ? (
people.map((member) => renderMemberCard(member, false))
) : (
<p className="text-sm text-muted-foreground">
{membersQuery.isLoading
? "Loading members..."
: "No people found."}
</p>
)}
</div>
</section>
<section className="space-y-2.5">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold tracking-tight">Bots</h2>
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{bots.length}
</span>
{hasControllableManagedBots ? (
<MembersSidebarAgentControls
canBulkRemove={hasRemovableManagedBots}
canBulkRespawn={hasControllableManagedBots}
canBulkStop={hasStoppableManagedBots}
disabled={isActionPending || isArchived}
onRemoveAll={() => {
void handleRemoveAll();
}}
onRespawnAll={() => {
void handleRespawnAll();
}}
onStopAll={() => {
void handleStopAll();
}}
/>
) : null}
</div>
<div className="space-y-2" data-testid="members-sidebar-bots">
{bots.length > 0 ? (
bots.map((member) => renderMemberCard(member, true))
) : (
<p className="text-sm text-muted-foreground">
{membersQuery.isLoading
? "Loading members..."
: "No bots found."}
</p>
)}
</div>
</section>
<section className="space-y-2.5">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold tracking-tight">Bots</h2>
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{bots.length}
</span>
{hasControllableManagedBots ? (
<MembersSidebarAgentControls
canBulkRemove={hasRemovableManagedBots}
canBulkRespawn={hasControllableManagedBots}
canBulkStop={hasStoppableManagedBots}
disabled={isActionPending || isArchived}
onRemoveAll={() => {
void handleRemoveAll();
}}
onRespawnAll={() => {
void handleRespawnAll();
}}
onStopAll={() => {
void handleStopAll();
}}
/>
) : null}
</div>
<div className="space-y-2" data-testid="members-sidebar-bots">
{bots.length > 0 ? (
bots.map((member) => renderMemberCard(member, true))
) : (
<p className="text-sm text-muted-foreground">
{membersQuery.isLoading
? "Loading members..."
: "No bots found."}
</p>
)}
</div>
</section>
{changeRoleError ? (
<p
className="text-sm text-destructive"
data-testid="members-sidebar-action-error"
>
{changeRoleError}
</p>
) : null}
</div>
</SheetContent>
</Sheet>
{changeRoleError ? (
<p
className="text-sm text-destructive"
data-testid="members-sidebar-action-error"
>
{changeRoleError}
</p>
) : null}
</div>
</SheetContent>
</Sheet>
<EditRespondToDialog
agent={editRespondToAgent}
currentPubkey={currentPubkey}
onOpenChange={(dialogOpen) => {
if (!dialogOpen) setEditRespondToAgent(null);
}}
open={editRespondToAgent !== null}
/>
</>
);
}
function EditRespondToDialog({
agent,
currentPubkey,
onOpenChange,
open,
}: {
agent: ManagedAgent | null;
currentPubkey?: string;
onOpenChange: (open: boolean) => void;
open: boolean;
}) {
const updateMutation = useUpdateManagedAgentMutation();
const [respondTo, setRespondTo] = React.useState<RespondToMode>("owner-only");
const [respondToAllowlist, setRespondToAllowlist] = React.useState<string[]>(
[],
);
React.useEffect(() => {
if (agent) {
setRespondTo(agent.respondTo);
setRespondToAllowlist([...agent.respondToAllowlist]);
}
}, [agent]);
const respondToValid =
respondTo !== "allowlist" || respondToAllowlist.length > 0;
async function handleSave() {
if (!agent) return;
await updateMutation.mutateAsync({
pubkey: agent.pubkey,
respondTo,
respondToAllowlist:
respondTo === "allowlist" ? respondToAllowlist : undefined,
});
onOpenChange(false);
}
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Edit respond-to</DialogTitle>
<DialogDescription>
Choose who {agent?.name ?? "this agent"} responds to.
</DialogDescription>
</DialogHeader>
<CreateAgentRespondToField
allowlist={respondToAllowlist}
disabled={updateMutation.isPending}
mode={respondTo}
onAllowlistChange={setRespondToAllowlist}
onModeChange={setRespondTo}
ownerPubkey={currentPubkey}
/>
{updateMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{updateMutation.error.message}
</p>
) : null}
<div className="flex justify-end gap-2">
<Button
onClick={() => onOpenChange(false)}
size="sm"
type="button"
variant="outline"
>
Cancel
</Button>
<Button
disabled={!respondToValid || updateMutation.isPending}
onClick={() => void handleSave()}
size="sm"
type="button"
>
{updateMutation.isPending ? "Saving..." : "Save"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,6 +1,7 @@
import {
Activity,
Ellipsis,
Pencil,
Play,
RotateCcw,
Shield,
@@ -43,6 +44,7 @@ type MembersSidebarMemberCardProps = {
memberIsBot: boolean;
memberLabel: string;
onChangeRole: (member: ChannelMember, role: string) => void;
onEditRespondTo?: (agent: ManagedAgent) => void;
onManagedAgentAction: (agent: ManagedAgent) => void;
onRemoveMember: (member: ChannelMember) => void;
onViewActivity?: (pubkey: string) => void;
@@ -58,6 +60,17 @@ function formatRoleLabel(member: ChannelMember, memberIsBot: boolean) {
return `${member.role[0]?.toUpperCase() ?? ""}${member.role.slice(1)}`;
}
function formatRespondToLabel(agent: ManagedAgent) {
switch (agent.respondTo) {
case "anyone":
return "Anyone";
case "allowlist":
return `Allowlist (${agent.respondToAllowlist.length})`;
default:
return "Owner only";
}
}
function formatManagedAgentStatus(agent: ManagedAgent) {
switch (agent.status) {
case "running":
@@ -82,6 +95,7 @@ export function MembersSidebarMemberCard({
memberIsBot,
memberLabel,
onChangeRole,
onEditRespondTo,
onManagedAgentAction,
onRemoveMember,
onViewActivity,
@@ -129,13 +143,22 @@ export function MembersSidebarMemberCard({
{roleLabel}
</Badge>
{managedAgent ? (
<Badge
className="shrink-0"
data-testid={`sidebar-managed-agent-status-${member.pubkey}`}
variant="secondary"
>
{formatManagedAgentStatus(managedAgent)}
</Badge>
<>
<Badge
className="shrink-0"
data-testid={`sidebar-managed-agent-status-${member.pubkey}`}
variant="secondary"
>
{formatManagedAgentStatus(managedAgent)}
</Badge>
<Badge
className="shrink-0"
data-testid={`sidebar-managed-agent-respond-to-${member.pubkey}`}
variant="outline"
>
{formatRespondToLabel(managedAgent)}
</Badge>
</>
) : null}
</div>
<p className="truncate font-mono text-[10px] text-muted-foreground/50">
@@ -153,6 +176,7 @@ export function MembersSidebarMemberCard({
member={member}
memberIsBot={memberIsBot}
onChangeRole={onChangeRole}
onEditRespondTo={onEditRespondTo}
onManagedAgentAction={onManagedAgentAction}
onRemoveMember={onRemoveMember}
onViewActivity={onViewActivity}
@@ -173,6 +197,7 @@ function MemberActionsMenu({
member,
memberIsBot,
onChangeRole,
onEditRespondTo,
onManagedAgentAction,
onRemoveMember,
onViewActivity,
@@ -185,6 +210,7 @@ function MemberActionsMenu({
member: ChannelMember;
memberIsBot: boolean;
onChangeRole: (member: ChannelMember, role: string) => void;
onEditRespondTo?: (agent: ManagedAgent) => void;
onManagedAgentAction: (agent: ManagedAgent) => void;
onRemoveMember: (member: ChannelMember) => void;
onViewActivity?: (pubkey: string) => void;
@@ -227,6 +253,16 @@ function MemberActionsMenu({
{getManagedAgentActionIcon(managedAgent)}
{getManagedAgentPrimaryActionLabel(managedAgent)}
</DropdownMenuItem>
{onEditRespondTo ? (
<DropdownMenuItem
data-testid={`sidebar-edit-respond-to-${member.pubkey}`}
disabled={disabled}
onClick={() => onEditRespondTo(managedAgent)}
>
<Pencil className="h-4 w-4" />
Edit respond-to...
</DropdownMenuItem>
) : null}
{canRemoveMember || showChangeRole ? (
<DropdownMenuSeparator />
) : null}

View File

@@ -1,4 +1,9 @@
import type { Channel, ChannelMember, RelayEvent } from "@/shared/api/types";
import type {
Channel,
ChannelMember,
RelayEvent,
RespondToMode,
} from "@/shared/api/types";
import type {
TimelineMessage,
@@ -130,6 +135,8 @@ export function formatTimelineMessages(
members?: ChannelMember[],
/** Map from lowercase pubkey → persona display name for bot messages. */
personaLookup?: Map<string, string>,
/** Map from lowercase pubkey → respond-to mode for bot messages. */
respondToLookup?: Map<string, RespondToMode>,
): TimelineMessage[] {
const currentPubkeyLower = currentPubkey?.toLowerCase();
const roleByPubkey = new Map<string, string>();
@@ -328,6 +335,10 @@ export function formatTimelineMessages(
role === "bot"
? personaLookup?.get(authorPubkey.toLowerCase())
: undefined,
respondTo:
role === "bot"
? respondToLookup?.get(authorPubkey.toLowerCase())
: undefined,
time: formatTime(event.created_at),
body: edit ? edit.content : event.content,
parentId: thread.parentId,

View File

@@ -18,6 +18,8 @@ export type TimelineMessage = {
role?: string;
/** For bot messages, the display name of the persona this bot was created from. */
personaDisplayName?: string;
/** For bot messages, the respond-to mode (who can interact with this bot). */
respondTo?: "owner-only" | "allowlist" | "anyone";
time: string;
body: string;
parentId?: string | null;

View File

@@ -132,14 +132,37 @@ export const MessageRow = React.memo(
? "rounded-md"
: "rounded-xl";
const respondToDotColor =
message.respondTo === "anyone"
? "bg-emerald-500"
: message.respondTo === "allowlist"
? "bg-amber-500"
: null;
const avatarNode = (
<UserAvatar
accent={message.accent}
avatarUrl={message.avatarUrl ?? null}
className={cn("shrink-0", avatarSizeClass)}
displayName={message.author}
testId="message-avatar"
/>
<div className="relative shrink-0">
<UserAvatar
accent={message.accent}
avatarUrl={message.avatarUrl ?? null}
className={cn("shrink-0", avatarSizeClass)}
displayName={message.author}
testId="message-avatar"
/>
{respondToDotColor && !isThreadReplyLayout ? (
<span
className={cn(
"absolute -bottom-0.5 -right-0.5 flex h-3 w-3 items-center justify-center rounded-full bg-background",
)}
title={
message.respondTo === "anyone"
? "Responds to anyone"
: "Responds to allowlist"
}
>
<span className={cn("h-2 w-2 rounded-full", respondToDotColor)} />
</span>
) : null}
</div>
);
const authorNode = message.pubkey ? (

View File

@@ -29,6 +29,11 @@ async function gotoApp(page: import("@playwright/test").Page) {
throw lastError;
}
async function openPersonaCatalog(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "New" }).click();
await page.getByText("Choose from Catalog...").click();
}
async function getCatalogOrder(page: import("@playwright/test").Page) {
return page
.locator('[data-testid^="persona-catalog-card-target-"]')
@@ -139,11 +144,8 @@ test("built-in personas are chosen from the dialog and can be selected", async (
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await expect(page.getByTestId("agents-library-personas")).toContainText(
"No agents yet",
);
await expect(page.getByTestId("agents-persona-catalog")).toHaveCount(0);
await page.getByTestId("open-persona-catalog").click();
await expect(page.getByTestId("agents-library-personas")).toBeVisible();
await openPersonaCatalog(page);
await expect(page.getByTestId("persona-catalog-dialog")).toContainText(
"Scout",
);
@@ -188,9 +190,9 @@ test("built-in personas are chosen from the dialog and can be selected", async (
.locator("[data-sonner-toast]")
.filter({ hasText: "Deselected Scout from My Agents." }),
).toBeVisible();
await expect(page.getByTestId("agents-library-personas")).not.toContainText(
"Scout",
);
await expect(
page.getByTestId("persona-catalog-card-target-builtin:scout"),
).toHaveAttribute("aria-pressed", "false");
await expect.poll(() => getCatalogOrder(page)).toEqual(initialCatalogOrder);
});
@@ -199,7 +201,7 @@ test("persona catalog can reopen from the populated library header", async ({
}) => {
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByTestId("open-persona-catalog").click();
await openPersonaCatalog(page);
await page.getByTestId("persona-catalog-card-target-builtin:scout").click();
await expect(page.getByTestId("agents-library-personas")).toContainText(
@@ -207,7 +209,7 @@ test("persona catalog can reopen from the populated library header", async ({
);
await page.getByTestId("persona-catalog-dialog-done").click();
await page.getByTestId("open-persona-catalog").click();
await openPersonaCatalog(page);
await expect(page.getByTestId("persona-catalog-dialog")).toBeVisible();
await expect(
@@ -220,7 +222,7 @@ test("persona catalog chooser order stays stable when selection changes", async
}) => {
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByTestId("open-persona-catalog").click();
await openPersonaCatalog(page);
const before = await getCatalogOrder(page);
@@ -239,7 +241,7 @@ test("catalog details sheet shows the full persona details", async ({
}) => {
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByTestId("open-persona-catalog").click();
await openPersonaCatalog(page);
await page.getByTestId("persona-catalog-details-builtin:scout").click();
const detailSelectionTarget = page.getByTestId(
@@ -296,7 +298,7 @@ test("built-in deselection failures show up in Persona Catalog", async ({
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByTestId("open-persona-catalog").click();
await openPersonaCatalog(page);
await page.getByTestId("persona-catalog-card-target-builtin:scout").click();
await invokeTauri(page, "create_team", {

View File

@@ -106,7 +106,8 @@ test("create agent supports parallelism and system prompt overrides", async ({
await page.goto("/");
await page.getByTestId("open-agents-view").click();
await page.getByRole("button", { name: "Create agent" }).click();
await page.getByRole("button", { name: "New" }).click();
await page.getByText("Custom Agent").click();
await page.getByTestId("agent-name-input").fill(agentName);
await page.getByRole("button", { name: "Advanced setup" }).click();
@@ -121,11 +122,11 @@ test("create agent supports parallelism and system prompt overrides", async ({
).toBeVisible();
await page.getByRole("button", { name: "Done" }).click();
await expect(page.getByTestId("managed-agents-table")).toContainText(
await expect(page.getByTestId("agents-library-personas")).toContainText(
agentName,
);
const inlineLog = page
.getByTestId("managed-agents-table")
.getByTestId("agents-library-personas")
.getByTestId("managed-agent-log-content");
await expect(inlineLog).toContainText("parallelism=3");