mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add edit dialog for managed agents with relay profile sync (#277)
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
mintManagedAgentToken,
|
||||
startManagedAgent,
|
||||
stopManagedAgent,
|
||||
updateManagedAgent,
|
||||
} from "@/shared/api/tauri";
|
||||
import {
|
||||
createPersona,
|
||||
@@ -43,6 +44,7 @@ import type {
|
||||
CreateTeamInput,
|
||||
ManagedAgent,
|
||||
MintManagedAgentTokenInput,
|
||||
UpdateManagedAgentInput,
|
||||
UpdatePersonaInput,
|
||||
UpdateTeamInput,
|
||||
} from "@/shared/api/types";
|
||||
@@ -195,6 +197,29 @@ export function useCreateManagedAgentMutation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateManagedAgentMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: UpdateManagedAgentInput) => updateManagedAgent(input),
|
||||
onSuccess: (result) => {
|
||||
queryClient.setQueryData<ManagedAgent[]>(
|
||||
managedAgentsQueryKey,
|
||||
(current) => {
|
||||
if (!current) return current;
|
||||
return current.map((agent) =>
|
||||
agent.pubkey === result.agent.pubkey ? result.agent : agent,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey });
|
||||
await queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePersonaMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useUpdateManagedAgentMutation } from "@/features/agents/hooks";
|
||||
import type { ManagedAgent, UpdateManagedAgentInput } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import {
|
||||
CreateAgentBasicsFields,
|
||||
CreateAgentRuntimeFields,
|
||||
} from "./CreateAgentDialogSections";
|
||||
|
||||
export function EditAgentDialog({
|
||||
agent,
|
||||
open,
|
||||
onOpenChange,
|
||||
onUpdated,
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onUpdated?: (agent: ManagedAgent) => void;
|
||||
}) {
|
||||
const updateMutation = useUpdateManagedAgentMutation();
|
||||
|
||||
const [name, setName] = React.useState(agent.name);
|
||||
const [relayUrl, setRelayUrl] = React.useState(agent.relayUrl);
|
||||
const [acpCommand, setAcpCommand] = React.useState(agent.acpCommand);
|
||||
const [agentCommand, setAgentCommand] = React.useState(agent.agentCommand);
|
||||
const [agentArgs, setAgentArgs] = React.useState(agent.agentArgs.join(","));
|
||||
const [mcpCommand, setMcpCommand] = React.useState(agent.mcpCommand);
|
||||
const [mcpToolsets, setMcpToolsets] = React.useState(agent.mcpToolsets ?? "");
|
||||
const [turnTimeoutSeconds, setTurnTimeoutSeconds] = React.useState(
|
||||
String(agent.turnTimeoutSeconds),
|
||||
);
|
||||
const [parallelism, setParallelism] = React.useState(
|
||||
String(agent.parallelism),
|
||||
);
|
||||
const [systemPrompt, setSystemPrompt] = React.useState(
|
||||
agent.systemPrompt ?? "",
|
||||
);
|
||||
|
||||
// Reset form state only when the dialog opens or when switching to a different
|
||||
// agent. Omitting the full agent object and its array fields from deps prevents
|
||||
// the effect from firing on every 5s background poll (arrays are never
|
||||
// reference-equal across renders), which would wipe in-progress user edits.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — including agent fields would re-fire on every 5s poll and wipe edits
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setName(agent.name);
|
||||
setRelayUrl(agent.relayUrl);
|
||||
setAcpCommand(agent.acpCommand);
|
||||
setAgentCommand(agent.agentCommand);
|
||||
setAgentArgs(agent.agentArgs.join(","));
|
||||
setMcpCommand(agent.mcpCommand);
|
||||
setMcpToolsets(agent.mcpToolsets ?? "");
|
||||
setTurnTimeoutSeconds(String(agent.turnTimeoutSeconds));
|
||||
setParallelism(String(agent.parallelism));
|
||||
setSystemPrompt(agent.systemPrompt ?? "");
|
||||
updateMutation.reset();
|
||||
}
|
||||
}, [open, agent.pubkey]);
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
onOpenChange(next);
|
||||
}
|
||||
|
||||
const parallelismValid =
|
||||
parallelism.trim() === "" ||
|
||||
!Number.isNaN(Number.parseInt(parallelism, 10));
|
||||
const timeoutValid =
|
||||
turnTimeoutSeconds.trim() === "" ||
|
||||
!Number.isNaN(Number.parseInt(turnTimeoutSeconds, 10));
|
||||
// Block clearing a previously-set command to empty — the backend has no
|
||||
// "clear to None" path for Option<String> fields, so an empty string would
|
||||
// cause a runtime failure.
|
||||
const acpCommandValid = !(agent.acpCommand && acpCommand.trim() === "");
|
||||
const mcpCommandValid = !(agent.mcpCommand && mcpCommand.trim() === "");
|
||||
const canSubmit =
|
||||
name.trim().length > 0 &&
|
||||
parallelismValid &&
|
||||
timeoutValid &&
|
||||
acpCommandValid &&
|
||||
mcpCommandValid &&
|
||||
!updateMutation.isPending;
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
const parsedParallelism = Number.parseInt(parallelism, 10);
|
||||
const parsedTimeout = Number.parseInt(turnTimeoutSeconds, 10);
|
||||
const parsedArgs = agentArgs
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0);
|
||||
|
||||
const input: UpdateManagedAgentInput = {
|
||||
pubkey: agent.pubkey,
|
||||
name: name.trim() !== agent.name ? name.trim() : undefined,
|
||||
relayUrl:
|
||||
relayUrl.trim() !== agent.relayUrl ? relayUrl.trim() : undefined,
|
||||
acpCommand:
|
||||
acpCommand.trim() !== agent.acpCommand
|
||||
? acpCommand.trim()
|
||||
: undefined,
|
||||
agentCommand:
|
||||
agentCommand.trim() !== agent.agentCommand
|
||||
? agentCommand.trim()
|
||||
: undefined,
|
||||
agentArgs:
|
||||
parsedArgs.join(",") !== agent.agentArgs.join(",")
|
||||
? parsedArgs
|
||||
: undefined,
|
||||
mcpCommand:
|
||||
mcpCommand.trim() !== agent.mcpCommand
|
||||
? mcpCommand.trim()
|
||||
: undefined,
|
||||
mcpToolsets:
|
||||
(mcpToolsets.trim() || null) !== agent.mcpToolsets
|
||||
? mcpToolsets.trim() || null
|
||||
: undefined,
|
||||
turnTimeoutSeconds:
|
||||
parsedTimeout > 0 && parsedTimeout !== agent.turnTimeoutSeconds
|
||||
? parsedTimeout
|
||||
: undefined,
|
||||
parallelism:
|
||||
parsedParallelism > 0 && parsedParallelism !== agent.parallelism
|
||||
? parsedParallelism
|
||||
: undefined,
|
||||
// Use tri-state: send null to clear, value to set, omit if unchanged.
|
||||
systemPrompt:
|
||||
(systemPrompt.trim() || null) !== agent.systemPrompt
|
||||
? systemPrompt.trim() || null
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const result = await updateMutation.mutateAsync(input);
|
||||
if (result.profileSyncError) {
|
||||
console.warn("Relay profile sync failed:", result.profileSyncError);
|
||||
}
|
||||
handleOpenChange(false);
|
||||
onUpdated?.(result.agent);
|
||||
} catch {
|
||||
// React Query stores the error; keep dialog open and render it inline.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogContent className="max-w-3xl overflow-hidden p-0">
|
||||
<div className="flex max-h-[85vh] flex-col">
|
||||
<DialogHeader className="shrink-0 border-b border-border/60 px-6 py-5 pr-14">
|
||||
<DialogTitle>Edit agent</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update configuration for{" "}
|
||||
<span className="font-medium">{agent.name}</span>. Changes take
|
||||
effect on the next start.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 py-5">
|
||||
<CreateAgentBasicsFields name={name} onNameChange={setName} />
|
||||
|
||||
<CreateAgentRuntimeFields
|
||||
acpCommand={acpCommand}
|
||||
agentArgs={agentArgs}
|
||||
agentCommand={agentCommand}
|
||||
mcpCommand={mcpCommand}
|
||||
mcpToolsets={mcpToolsets}
|
||||
onAcpCommandChange={setAcpCommand}
|
||||
onAgentArgsChange={setAgentArgs}
|
||||
onAgentCommandChange={setAgentCommand}
|
||||
onMcpCommandChange={setMcpCommand}
|
||||
onMcpToolsetsChange={setMcpToolsets}
|
||||
onParallelismChange={setParallelism}
|
||||
onRelayUrlChange={setRelayUrl}
|
||||
onSystemPromptChange={setSystemPrompt}
|
||||
onTurnTimeoutChange={setTurnTimeoutSeconds}
|
||||
parallelism={parallelism}
|
||||
relayUrl={relayUrl}
|
||||
selectedProviderId="custom"
|
||||
systemPrompt={systemPrompt}
|
||||
turnTimeoutSeconds={turnTimeoutSeconds}
|
||||
/>
|
||||
|
||||
{updateMutation.error instanceof Error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{updateMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 justify-end gap-2 border-t border-border/60 px-6 py-4">
|
||||
<Button
|
||||
onClick={() => handleOpenChange(false)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canSubmit}
|
||||
onClick={() => void handleSubmit()}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
{updateMutation.isPending ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@@ -5,6 +7,7 @@ import {
|
||||
Ellipsis,
|
||||
FileText,
|
||||
KeyRound,
|
||||
Pencil,
|
||||
Play,
|
||||
Power,
|
||||
Square,
|
||||
@@ -26,6 +29,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { EditAgentDialog } from "./EditAgentDialog";
|
||||
import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel";
|
||||
import { ModelPicker } from "./ModelPicker";
|
||||
import { truncatePubkey } from "./agentUi";
|
||||
@@ -313,111 +317,128 @@ function AgentActionsMenu({
|
||||
onStop: (pubkey: string) => void;
|
||||
onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void;
|
||||
}) {
|
||||
const [editOpen, setEditOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
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"
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
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()}
|
||||
>
|
||||
<Ellipsis className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{agent.backend.type === "provider" ? (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
disabled={isActionPending}
|
||||
onClick={() => onStart(agent.pubkey)}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
{isActive ? "Redeploy" : "Deploy"}
|
||||
</DropdownMenuItem>
|
||||
{agent.backend.type === "provider" ? (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
disabled={isActionPending}
|
||||
onClick={() => onStart(agent.pubkey)}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
{isActive ? "Redeploy" : "Deploy"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={isActionPending}
|
||||
onClick={() => onStop(agent.pubkey)}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Shutdown
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : isActive ? (
|
||||
<DropdownMenuItem
|
||||
disabled={isActionPending}
|
||||
onClick={() => onStop(agent.pubkey)}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Shutdown
|
||||
Stop
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : isActive ? (
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
disabled={isActionPending}
|
||||
onClick={() => onStart(agent.pubkey)}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
Spawn
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{agent.backend.type !== "provider" ? (
|
||||
<DropdownMenuItem onClick={() => setEditOpen(true)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={isActionPending}
|
||||
onClick={() => onStop(agent.pubkey)}
|
||||
onClick={() => onAddToChannel(agent)}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Add to channel
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={isActionPending}
|
||||
onClick={() => onStart(agent.pubkey)}
|
||||
onClick={() => onMintToken(agent.pubkey, agent.name)}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
Spawn
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Mint token
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={isActionPending}
|
||||
onClick={() => onAddToChannel(agent)}
|
||||
>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Add to channel
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={isActionPending}
|
||||
onClick={() => onMintToken(agent.pubkey, agent.name)}
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Mint token
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(agent.pubkey)}
|
||||
>
|
||||
<Clipboard className="h-4 w-4" />
|
||||
Copy pubkey
|
||||
</DropdownMenuItem>
|
||||
|
||||
{agent.backend.type === "local" ? (
|
||||
<DropdownMenuItem onClick={() => onOpenLogs(agent.pubkey)}>
|
||||
<FileText className="h-4 w-4" />
|
||||
View logs
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
|
||||
{agent.backend.type === "local" ? (
|
||||
<DropdownMenuItem
|
||||
disabled={isActionPending}
|
||||
onClick={() =>
|
||||
onToggleStartOnAppLaunch(agent.pubkey, !agent.startOnAppLaunch)
|
||||
}
|
||||
onClick={() => navigator.clipboard.writeText(agent.pubkey)}
|
||||
>
|
||||
<Power className="h-4 w-4" />
|
||||
{agent.startOnAppLaunch
|
||||
? "Disable auto-start"
|
||||
: "Enable auto-start"}
|
||||
<Clipboard className="h-4 w-4" />
|
||||
Copy pubkey
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
{agent.backend.type === "local" ? (
|
||||
<DropdownMenuItem onClick={() => onOpenLogs(agent.pubkey)}>
|
||||
<FileText className="h-4 w-4" />
|
||||
View logs
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={isActionPending}
|
||||
onClick={() => onDelete(agent.pubkey)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{agent.backend.type === "local" ? (
|
||||
<DropdownMenuItem
|
||||
disabled={isActionPending}
|
||||
onClick={() =>
|
||||
onToggleStartOnAppLaunch(agent.pubkey, !agent.startOnAppLaunch)
|
||||
}
|
||||
>
|
||||
<Power className="h-4 w-4" />
|
||||
{agent.startOnAppLaunch
|
||||
? "Disable auto-start"
|
||||
: "Enable auto-start"}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={isActionPending}
|
||||
onClick={() => onDelete(agent.pubkey)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<EditAgentDialog
|
||||
agent={agent}
|
||||
onOpenChange={setEditOpen}
|
||||
open={editOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user