Add edit dialog for managed agents with relay profile sync (#277)

This commit is contained in:
Will Pfleger
2026-04-13 18:15:43 +00:00
committed by GitHub
parent 4c6c897d11
commit ccecf7e362
9 changed files with 519 additions and 136 deletions
+25
View File
@@ -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}
/>
</>
);
}