mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
feat(desktop): persona-first agent UX + bug fixes (#570)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
68
desktop/src/features/agents/ui/AgentGroupRows.tsx
Normal file
68
desktop/src/features/agents/ui/AgentGroupRows.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
93
desktop/src/features/agents/ui/PersonaActionsMenu.tsx
Normal file
93
desktop/src/features/agents/ui/PersonaActionsMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
561
desktop/src/features/agents/ui/UnifiedAgentsSection.tsx
Normal file
561
desktop/src/features/agents/ui/UnifiedAgentsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user