mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Redesign Agents page: compact table with actions menu (#117)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -125,6 +125,19 @@ export function useCreateManagedAgentMutation() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateManagedAgentInput) => createManagedAgent(input),
|
||||
onSuccess: (created) => {
|
||||
queryClient.setQueryData<ManagedAgent[]>(
|
||||
managedAgentsQueryKey,
|
||||
(current) => {
|
||||
const next = current ?? [];
|
||||
|
||||
return [
|
||||
created.agent,
|
||||
...next.filter((agent) => agent.pubkey !== created.agent.pubkey),
|
||||
];
|
||||
},
|
||||
);
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey });
|
||||
await queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey });
|
||||
|
||||
@@ -58,31 +58,26 @@ export function AgentsView() {
|
||||
}),
|
||||
[managedAgentsQuery.data],
|
||||
);
|
||||
const [selectedAgentPubkey, setSelectedAgentPubkey] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const selectedAgent =
|
||||
managedAgents.find((agent) => agent.pubkey === selectedAgentPubkey) ??
|
||||
managedAgents[0] ??
|
||||
null;
|
||||
const managedAgentLogQuery = useManagedAgentLogQuery(
|
||||
selectedAgent?.pubkey ?? null,
|
||||
const [logAgentPubkey, setLogAgentPubkey] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const logAgent =
|
||||
managedAgents.find((agent) => agent.pubkey === logAgentPubkey) ?? null;
|
||||
const managedAgentLogQuery = useManagedAgentLogQuery(logAgentPubkey);
|
||||
const managedPubkeys = React.useMemo(
|
||||
() => new Set(managedAgents.map((agent) => agent.pubkey)),
|
||||
[managedAgents],
|
||||
);
|
||||
|
||||
// Clear log selection if the agent was removed
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
selectedAgentPubkey &&
|
||||
managedAgents.some((agent) => agent.pubkey === selectedAgentPubkey)
|
||||
logAgentPubkey &&
|
||||
!managedAgents.some((agent) => agent.pubkey === logAgentPubkey)
|
||||
) {
|
||||
return;
|
||||
setLogAgentPubkey(null);
|
||||
}
|
||||
|
||||
setSelectedAgentPubkey(managedAgents[0]?.pubkey ?? null);
|
||||
}, [managedAgents, selectedAgentPubkey]);
|
||||
}, [managedAgents, logAgentPubkey]);
|
||||
|
||||
async function handleStart(pubkey: string) {
|
||||
setActionNoticeMessage(null);
|
||||
@@ -116,8 +111,8 @@ export function AgentsView() {
|
||||
|
||||
try {
|
||||
await deleteMutation.mutateAsync(pubkey);
|
||||
if (selectedAgentPubkey === pubkey) {
|
||||
setSelectedAgentPubkey(null);
|
||||
if (logAgentPubkey === pubkey) {
|
||||
setLogAgentPubkey(null);
|
||||
}
|
||||
} catch (error) {
|
||||
setActionErrorMessage(
|
||||
@@ -240,7 +235,6 @@ export function AgentsView() {
|
||||
void handleMintToken(pubkey, name);
|
||||
}}
|
||||
onRefresh={handleRefresh}
|
||||
onSelect={setSelectedAgentPubkey}
|
||||
onStart={(pubkey) => {
|
||||
void handleStart(pubkey);
|
||||
}}
|
||||
@@ -250,7 +244,7 @@ export function AgentsView() {
|
||||
onToggleStartOnAppLaunch={(pubkey, startOnAppLaunch) => {
|
||||
void handleToggleStartOnAppLaunch(pubkey, startOnAppLaunch);
|
||||
}}
|
||||
selectedAgentPubkey={selectedAgent?.pubkey ?? null}
|
||||
onViewLogs={setLogAgentPubkey}
|
||||
/>
|
||||
|
||||
<RelayDirectorySection
|
||||
@@ -273,13 +267,14 @@ export function AgentsView() {
|
||||
}
|
||||
isLoading={managedAgentLogQuery.isLoading}
|
||||
logContent={managedAgentLogQuery.data?.content ?? null}
|
||||
selectedAgent={selectedAgent}
|
||||
selectedAgent={logAgent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateAgentDialog
|
||||
onCreated={(result) => {
|
||||
setLogAgentPubkey(result.agent.pubkey);
|
||||
setCreatedAgent(result);
|
||||
}}
|
||||
onOpenChange={setIsCreateOpen}
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import {
|
||||
KeyRound,
|
||||
Play,
|
||||
Square,
|
||||
TerminalSquare,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { ModelPicker } from "./ModelPicker";
|
||||
import { formatTimestamp, truncatePubkey } from "./agentUi";
|
||||
|
||||
export function ManagedAgentCard({
|
||||
agent,
|
||||
onToggleStartOnAppLaunch,
|
||||
onAddToChannel,
|
||||
isSelected,
|
||||
onDelete,
|
||||
onMintToken,
|
||||
onModelChanged,
|
||||
onSelect,
|
||||
onStart,
|
||||
onStop,
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void;
|
||||
onAddToChannel: (agent: ManagedAgent) => void;
|
||||
isSelected: boolean;
|
||||
onDelete: (pubkey: string) => void;
|
||||
onMintToken: (pubkey: string, name: string) => void;
|
||||
onModelChanged?: () => void;
|
||||
onSelect: (pubkey: string) => void;
|
||||
onStart: (pubkey: string) => void;
|
||||
onStop: (pubkey: string) => void;
|
||||
}) {
|
||||
const statusBadgeClass =
|
||||
agent.status === "running"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground";
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"rounded-3xl border p-4 shadow-sm transition-colors",
|
||||
isSelected
|
||||
? "border-primary/60 bg-primary/5"
|
||||
: "border-border/70 bg-card/80 hover:bg-card",
|
||||
)}
|
||||
data-testid={`managed-agent-${agent.pubkey}`}
|
||||
>
|
||||
<button
|
||||
className="w-full text-left"
|
||||
onClick={() => onSelect(agent.pubkey)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold tracking-tight">
|
||||
{agent.name}
|
||||
</h3>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em]",
|
||||
statusBadgeClass,
|
||||
)}
|
||||
>
|
||||
{agent.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{truncatePubkey(agent.pubkey)}
|
||||
{agent.pid ? ` · pid ${agent.pid}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
<TerminalSquare className="h-4 w-4" />
|
||||
<span>{agent.agentCommand}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-border/60 bg-background/70 px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Relay
|
||||
</p>
|
||||
<p className="mt-1 truncate text-sm">{agent.relayUrl}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-background/70 px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Auth
|
||||
</p>
|
||||
<p className="mt-1 text-sm">
|
||||
{agent.hasApiToken ? "Bearer token saved" : "Key-only dev mode"}
|
||||
</p>
|
||||
</div>
|
||||
<ModelPicker agent={agent} onModelChanged={onModelChanged} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<CopyButton label="Copy pubkey" value={agent.pubkey} />
|
||||
|
||||
{agent.status === "running" ? (
|
||||
<Button
|
||||
onClick={() => onStop(agent.pubkey)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => onStart(agent.pubkey)} size="sm" type="button">
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
Spawn
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => onAddToChannel(agent)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5" />
|
||||
Add to channel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => onMintToken(agent.pubkey, agent.name)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<KeyRound className="h-3.5 w-3.5" />
|
||||
Mint token
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() =>
|
||||
onToggleStartOnAppLaunch(agent.pubkey, !agent.startOnAppLaunch)
|
||||
}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{agent.startOnAppLaunch
|
||||
? "Launch on open: on"
|
||||
: "Launch on open: off"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => onDelete(agent.pubkey)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2 text-xs text-muted-foreground sm:grid-cols-2">
|
||||
<p>Started {formatTimestamp(agent.lastStartedAt)}</p>
|
||||
<p>Stopped {formatTimestamp(agent.lastStoppedAt)}</p>
|
||||
</div>
|
||||
|
||||
{agent.lastError ? (
|
||||
<p className="mt-3 rounded-2xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{agent.lastError}
|
||||
</p>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export function ManagedAgentLogPanel({
|
||||
</div>
|
||||
|
||||
{!selectedAgent ? (
|
||||
<div className="mt-4 rounded-3xl border border-dashed border-border/80 bg-background/70 px-6 py-10 text-center">
|
||||
<div className="mt-4 rounded-xl border border-dashed border-border/80 bg-background/70 px-6 py-10 text-center">
|
||||
<p className="text-sm font-semibold tracking-tight">
|
||||
No local agent selected
|
||||
</p>
|
||||
@@ -42,14 +42,14 @@ export function ManagedAgentLogPanel({
|
||||
</p>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="mt-4 rounded-3xl border border-border/70 bg-background/80 p-4">
|
||||
<div className="mt-4 rounded-xl border border-border/70 bg-background/80 p-4">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="mt-3 h-4 w-full" />
|
||||
<Skeleton className="mt-2 h-4 w-full" />
|
||||
<Skeleton className="mt-2 h-4 w-3/4" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 overflow-hidden rounded-3xl border border-border/70 bg-[#17171d] text-[12px] text-zinc-100">
|
||||
<div className="mt-4 overflow-hidden rounded-xl border border-border/70 bg-[#17171d] text-[12px] text-zinc-100">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-2 text-[11px] uppercase tracking-[0.18em] text-zinc-400">
|
||||
<span>{selectedAgent.name}</span>
|
||||
<span>{selectedAgent.status}</span>
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
import { Plus, RefreshCcw } from "lucide-react";
|
||||
import {
|
||||
Clipboard,
|
||||
Ellipsis,
|
||||
FileText,
|
||||
KeyRound,
|
||||
Play,
|
||||
Plus,
|
||||
Power,
|
||||
RefreshCcw,
|
||||
Square,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import { ManagedAgentCard } from "./ManagedAgentCard";
|
||||
import { ModelPicker } from "./ModelPicker";
|
||||
import { truncatePubkey } from "./agentUi";
|
||||
|
||||
export function ManagedAgentsSection({
|
||||
actionErrorMessage,
|
||||
@@ -12,16 +33,15 @@ export function ManagedAgentsSection({
|
||||
error,
|
||||
isActionPending,
|
||||
isLoading,
|
||||
selectedAgentPubkey,
|
||||
onAddToChannel,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onMintToken,
|
||||
onRefresh,
|
||||
onSelect,
|
||||
onStart,
|
||||
onStop,
|
||||
onToggleStartOnAppLaunch,
|
||||
onViewLogs,
|
||||
}: {
|
||||
actionErrorMessage: string | null;
|
||||
actionNoticeMessage: string | null;
|
||||
@@ -29,16 +49,15 @@ export function ManagedAgentsSection({
|
||||
error: Error | null;
|
||||
isActionPending: boolean;
|
||||
isLoading: boolean;
|
||||
selectedAgentPubkey: string | null;
|
||||
onAddToChannel: (agent: ManagedAgent) => void;
|
||||
onCreate: () => void;
|
||||
onDelete: (pubkey: string) => void;
|
||||
onMintToken: (pubkey: string, name: string) => void;
|
||||
onRefresh: () => void;
|
||||
onSelect: (pubkey: string) => void;
|
||||
onStart: (pubkey: string) => void;
|
||||
onStop: (pubkey: string) => void;
|
||||
onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void;
|
||||
onViewLogs: (pubkey: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
@@ -64,22 +83,23 @@ export function ManagedAgentsSection({
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-3">
|
||||
<div className="overflow-hidden rounded-xl border border-border/70 bg-card/80 shadow-sm">
|
||||
{["first", "second"].map((key) => (
|
||||
<div
|
||||
className="rounded-3xl border border-border/70 bg-card/80 p-4"
|
||||
className="flex items-center gap-4 border-b border-border/60 px-4 py-3 last:border-b-0"
|
||||
key={key}
|
||||
>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="mt-3 h-4 w-48" />
|
||||
<Skeleton className="mt-4 h-16 w-full" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && agents.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center">
|
||||
<div className="rounded-xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center">
|
||||
<p className="text-sm font-semibold tracking-tight">
|
||||
No local agents yet
|
||||
</p>
|
||||
@@ -90,45 +110,43 @@ export function ManagedAgentsSection({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{agents.map((agent) => (
|
||||
<ManagedAgentCard
|
||||
agent={agent}
|
||||
isSelected={selectedAgentPubkey === agent.pubkey}
|
||||
key={agent.pubkey}
|
||||
onAddToChannel={(managedAgent) => {
|
||||
if (!isActionPending) {
|
||||
onAddToChannel(managedAgent);
|
||||
}
|
||||
}}
|
||||
onDelete={(pubkey) => {
|
||||
if (!isActionPending) {
|
||||
onDelete(pubkey);
|
||||
}
|
||||
}}
|
||||
onMintToken={(pubkey, name) => {
|
||||
if (!isActionPending) {
|
||||
onMintToken(pubkey, name);
|
||||
}
|
||||
}}
|
||||
onModelChanged={onRefresh}
|
||||
onSelect={onSelect}
|
||||
onStart={(pubkey) => {
|
||||
if (!isActionPending) {
|
||||
onStart(pubkey);
|
||||
}
|
||||
}}
|
||||
onStop={(pubkey) => {
|
||||
if (!isActionPending) {
|
||||
onStop(pubkey);
|
||||
}
|
||||
}}
|
||||
onToggleStartOnAppLaunch={(pubkey, startOnAppLaunch) => {
|
||||
if (!isActionPending) {
|
||||
onToggleStartOnAppLaunch(pubkey, startOnAppLaunch);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{!isLoading && agents.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-xl border border-border/70 bg-card/80 shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table
|
||||
className="w-full border-collapse text-left text-sm"
|
||||
data-testid="managed-agents-table"
|
||||
>
|
||||
<thead className="bg-muted/35 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Agent</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3">Model</th>
|
||||
<th className="px-4 py-3">Runtime</th>
|
||||
<th className="w-10 px-3 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{agents.map((agent) => (
|
||||
<ManagedAgentRow
|
||||
agent={agent}
|
||||
isActionPending={isActionPending}
|
||||
key={agent.pubkey}
|
||||
onAddToChannel={onAddToChannel}
|
||||
onDelete={onDelete}
|
||||
onMintToken={onMintToken}
|
||||
onModelChanged={onRefresh}
|
||||
onStart={onStart}
|
||||
onStop={onStop}
|
||||
onToggleStartOnAppLaunch={onToggleStartOnAppLaunch}
|
||||
onViewLogs={onViewLogs}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
@@ -150,3 +168,181 @@ export function ManagedAgentsSection({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ManagedAgentRow({
|
||||
agent,
|
||||
isActionPending,
|
||||
onAddToChannel,
|
||||
onDelete,
|
||||
onMintToken,
|
||||
onModelChanged,
|
||||
onStart,
|
||||
onStop,
|
||||
onToggleStartOnAppLaunch,
|
||||
onViewLogs,
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
isActionPending: boolean;
|
||||
onAddToChannel: (agent: ManagedAgent) => void;
|
||||
onDelete: (pubkey: string) => void;
|
||||
onMintToken: (pubkey: string, name: string) => void;
|
||||
onModelChanged?: () => void;
|
||||
onStart: (pubkey: string) => void;
|
||||
onStop: (pubkey: string) => void;
|
||||
onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void;
|
||||
onViewLogs: (pubkey: string) => void;
|
||||
}) {
|
||||
const isRunning = agent.status === "running";
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="border-b border-border/60 last:border-b-0 hover:bg-muted/30"
|
||||
data-testid={`managed-agent-${agent.pubkey}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<p className="truncate font-medium text-foreground">{agent.name}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{truncatePubkey(agent.pubkey)}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em]",
|
||||
isRunning
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{agent.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<ModelPicker agent={agent} onModelChanged={onModelChanged} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{agent.agentCommand}</td>
|
||||
<td className="px-3 py-3">
|
||||
<AgentActionsMenu
|
||||
agent={agent}
|
||||
isActionPending={isActionPending}
|
||||
isRunning={isRunning}
|
||||
onAddToChannel={onAddToChannel}
|
||||
onDelete={onDelete}
|
||||
onMintToken={onMintToken}
|
||||
onStart={onStart}
|
||||
onStop={onStop}
|
||||
onToggleStartOnAppLaunch={onToggleStartOnAppLaunch}
|
||||
onViewLogs={onViewLogs}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentActionsMenu({
|
||||
agent,
|
||||
isActionPending,
|
||||
isRunning,
|
||||
onAddToChannel,
|
||||
onDelete,
|
||||
onMintToken,
|
||||
onStart,
|
||||
onStop,
|
||||
onToggleStartOnAppLaunch,
|
||||
onViewLogs,
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
isActionPending: boolean;
|
||||
isRunning: boolean;
|
||||
onAddToChannel: (agent: ManagedAgent) => void;
|
||||
onDelete: (pubkey: string) => void;
|
||||
onMintToken: (pubkey: string, name: string) => void;
|
||||
onStart: (pubkey: string) => void;
|
||||
onStop: (pubkey: string) => void;
|
||||
onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void;
|
||||
onViewLogs: (pubkey: string) => void;
|
||||
}) {
|
||||
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"
|
||||
>
|
||||
<Ellipsis className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{isRunning ? (
|
||||
<DropdownMenuItem
|
||||
disabled={isActionPending}
|
||||
onClick={() => onStop(agent.pubkey)}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
disabled={isActionPending}
|
||||
onClick={() => onStart(agent.pubkey)}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
Spawn
|
||||
</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>
|
||||
|
||||
<DropdownMenuItem onClick={() => onViewLogs(agent.pubkey)}>
|
||||
<FileText className="h-4 w-4" />
|
||||
View logs
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={isActionPending}
|
||||
onClick={() =>
|
||||
onToggleStartOnAppLaunch(agent.pubkey, !agent.startOnAppLaunch)
|
||||
}
|
||||
>
|
||||
<Power className="h-4 w-4" />
|
||||
{agent.startOnAppLaunch ? "Disable auto-start" : "Enable auto-start"}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={isActionPending}
|
||||
onClick={() => onDelete(agent.pubkey)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,12 +39,17 @@ export function ModelPicker({
|
||||
}
|
||||
}, [agent.pubkey]);
|
||||
|
||||
// Auto-fetch on mount
|
||||
React.useEffect(() => {
|
||||
void fetchModels();
|
||||
}, [fetchModels]);
|
||||
|
||||
const currentValue = agent.model ?? modelsData?.agentDefaultModel ?? "";
|
||||
const displayLabel =
|
||||
agent.model ??
|
||||
(modelsData?.agentDefaultModel
|
||||
? `${modelsData.agentDefaultModel} (default)`
|
||||
: "Select model…");
|
||||
: "—");
|
||||
|
||||
const handleModelChange = async (modelId: string) => {
|
||||
setSaving(true);
|
||||
@@ -64,79 +69,41 @@ export function ModelPicker({
|
||||
}
|
||||
};
|
||||
|
||||
if (!modelsData && !loading && !error) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-background/70 px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Model
|
||||
</p>
|
||||
<Button
|
||||
className="mt-1 h-7 px-2 text-sm"
|
||||
onClick={fetchModels}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Discover models
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-background/70 px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Model
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Discovering…
|
||||
</div>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-destructive">
|
||||
Model
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-destructive">{error}</p>
|
||||
<Button
|
||||
className="mt-1 h-6 px-2 text-xs"
|
||||
<span className="inline-flex items-center gap-1.5 text-sm">
|
||||
<span className="text-destructive">Failed</span>
|
||||
<button
|
||||
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
||||
onClick={fetchModels}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
retry
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!modelsData?.supportsSwitching) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-background/70 px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Model
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Not configurable</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">{displayLabel}</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-background/70 px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Model
|
||||
</p>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="mt-1 h-7 max-w-full justify-start gap-1.5 rounded-full border border-border/50 bg-muted/45 px-2.5 text-xs font-medium text-foreground shadow-none hover:bg-muted/70"
|
||||
className="h-7 max-w-full justify-start gap-1.5 rounded-full border border-border/50 bg-muted/45 px-2.5 text-xs font-medium text-foreground shadow-none hover:bg-muted/70"
|
||||
disabled={saving}
|
||||
size="sm"
|
||||
type="button"
|
||||
@@ -164,10 +131,10 @@ export function ModelPicker({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{needsRestart ? (
|
||||
<p className="mt-1 text-[10px] text-amber-600 dark:text-amber-400">
|
||||
Restart agent to apply
|
||||
</p>
|
||||
<span className="text-[10px] text-amber-600 dark:text-amber-400">
|
||||
restart to apply
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export function RelayDirectorySection({
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="overflow-hidden rounded-3xl border border-border/70 bg-card/80 shadow-sm">
|
||||
<div className="overflow-hidden rounded-xl border border-border/70 bg-card/80 shadow-sm">
|
||||
<div className="grid gap-0">
|
||||
{["directory-1", "directory-2", "directory-3"].map((key) => (
|
||||
<div
|
||||
@@ -59,7 +59,7 @@ export function RelayDirectorySection({
|
||||
) : null}
|
||||
|
||||
{!isLoading && relayAgents.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center">
|
||||
<div className="rounded-xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center">
|
||||
<p className="text-sm font-semibold tracking-tight">
|
||||
No relay-visible agents yet
|
||||
</p>
|
||||
@@ -71,7 +71,7 @@ export function RelayDirectorySection({
|
||||
) : null}
|
||||
|
||||
{!isLoading && relayAgents.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-3xl border border-border/70 bg-card/80 shadow-sm">
|
||||
<div className="overflow-hidden rounded-xl border border-border/70 bg-card/80 shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table
|
||||
className="w-full border-collapse text-left text-sm"
|
||||
|
||||
@@ -1,16 +1,3 @@
|
||||
export function formatTimestamp(value: string | null) {
|
||||
if (!value) {
|
||||
return "Never";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function truncatePubkey(pubkey: string) {
|
||||
return `${pubkey.slice(0, 8)}…${pubkey.slice(-6)}`;
|
||||
}
|
||||
|
||||
@@ -90,6 +90,9 @@ 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(
|
||||
agentName,
|
||||
);
|
||||
await expect(page.getByTestId("managed-agent-log-content")).toContainText(
|
||||
"parallelism=3",
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user