mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
[codex] Polish workflow and agent run history UX (#253)
This commit is contained in:
@@ -40,7 +40,6 @@ import { AddAgentToChannelDialog } from "./AddAgentToChannelDialog";
|
||||
import { AddTeamToChannelDialog } from "./AddTeamToChannelDialog";
|
||||
import { BatchImportDialog } from "./BatchImportDialog";
|
||||
import { CreateAgentDialog } from "./CreateAgentDialog";
|
||||
import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel";
|
||||
import { ManagedAgentsSection } from "./ManagedAgentsSection";
|
||||
import { PersonaDialog } from "./PersonaDialog";
|
||||
import { PersonaDeleteDialog } from "./PersonaDeleteDialog";
|
||||
@@ -132,8 +131,6 @@ export function AgentsView() {
|
||||
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)),
|
||||
@@ -574,6 +571,13 @@ export function AgentsView() {
|
||||
}
|
||||
isActionPending={isActionPending}
|
||||
isLoading={managedAgentsQuery.isLoading}
|
||||
logContent={managedAgentLogQuery.data?.content ?? null}
|
||||
logError={
|
||||
managedAgentLogQuery.error instanceof Error
|
||||
? managedAgentLogQuery.error
|
||||
: null
|
||||
}
|
||||
logLoading={managedAgentLogQuery.isLoading}
|
||||
personaLabelsById={personaLabelsById}
|
||||
presenceLookup={managedPresenceQuery.data ?? {}}
|
||||
onAddToChannel={(agent) => {
|
||||
@@ -590,6 +594,7 @@ export function AgentsView() {
|
||||
onMintToken={(pubkey, name) => {
|
||||
void handleMintToken(pubkey, name);
|
||||
}}
|
||||
onSelectLogAgent={setLogAgentPubkey}
|
||||
onStart={(pubkey) => {
|
||||
void handleStart(pubkey);
|
||||
}}
|
||||
@@ -599,7 +604,7 @@ export function AgentsView() {
|
||||
onToggleStartOnAppLaunch={(pubkey, startOnAppLaunch) => {
|
||||
void handleToggleStartOnAppLaunch(pubkey, startOnAppLaunch);
|
||||
}}
|
||||
onViewLogs={setLogAgentPubkey}
|
||||
selectedLogAgentPubkey={logAgentPubkey}
|
||||
/>
|
||||
|
||||
<RelayDirectorySection
|
||||
@@ -613,17 +618,6 @@ export function AgentsView() {
|
||||
relayAgents={relayAgentsQuery.data ?? []}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ManagedAgentLogPanel
|
||||
error={
|
||||
managedAgentLogQuery.error instanceof Error
|
||||
? managedAgentLogQuery.error
|
||||
: null
|
||||
}
|
||||
isLoading={managedAgentLogQuery.isLoading}
|
||||
logContent={managedAgentLogQuery.data?.content ?? null}
|
||||
selectedAgent={logAgent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CircleAlert } from "lucide-react";
|
||||
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { describeLogFile } from "./agentUi";
|
||||
@@ -10,14 +11,29 @@ export function ManagedAgentLogPanel({
|
||||
isLoading,
|
||||
logContent,
|
||||
selectedAgent,
|
||||
variant = "section",
|
||||
}: {
|
||||
error: Error | null;
|
||||
isLoading: boolean;
|
||||
logContent: string | null;
|
||||
selectedAgent: ManagedAgent | null;
|
||||
variant?: "inline" | "section";
|
||||
}) {
|
||||
const isInline = variant === "inline";
|
||||
|
||||
if (!selectedAgent && isInline) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[28px] border border-border/70 bg-card/90 p-5 shadow-sm">
|
||||
<section
|
||||
className={cn(
|
||||
"border border-border/70 shadow-sm",
|
||||
isInline
|
||||
? "rounded-2xl bg-background/80 p-4"
|
||||
: "rounded-[28px] bg-card/90 p-5",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold tracking-tight">Harness log</h3>
|
||||
@@ -55,7 +71,10 @@ export function ManagedAgentLogPanel({
|
||||
<span>{selectedAgent.status}</span>
|
||||
</div>
|
||||
<pre
|
||||
className="max-h-[22rem] overflow-auto whitespace-pre-wrap px-4 py-4"
|
||||
className={cn(
|
||||
"overflow-auto whitespace-pre-wrap px-4 py-4",
|
||||
isInline ? "max-h-[18rem]" : "max-h-[22rem]",
|
||||
)}
|
||||
data-testid="managed-agent-log-content"
|
||||
>
|
||||
{logContent?.trim() ? logContent : "No log output yet."}
|
||||
|
||||
438
desktop/src/features/agents/ui/ManagedAgentRow.tsx
Normal file
438
desktop/src/features/agents/ui/ManagedAgentRow.tsx
Normal file
@@ -0,0 +1,438 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clipboard,
|
||||
Ellipsis,
|
||||
FileText,
|
||||
KeyRound,
|
||||
Play,
|
||||
Power,
|
||||
Square,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
|
||||
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
|
||||
import type {
|
||||
ManagedAgent,
|
||||
PresenceLookup,
|
||||
PresenceStatus,
|
||||
} from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel";
|
||||
import { ModelPicker } from "./ModelPicker";
|
||||
import { truncatePubkey } from "./agentUi";
|
||||
|
||||
export function ManagedAgentRow({
|
||||
agent,
|
||||
isActionPending,
|
||||
isLogSelected,
|
||||
logContent,
|
||||
logError,
|
||||
logLoading,
|
||||
personaLabelsById,
|
||||
presenceLookup,
|
||||
onAddToChannel,
|
||||
onDelete,
|
||||
onMintToken,
|
||||
onSelectLogAgent,
|
||||
onStart,
|
||||
onStop,
|
||||
onToggleStartOnAppLaunch,
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
isActionPending: boolean;
|
||||
isLogSelected: boolean;
|
||||
logContent: string | null;
|
||||
logError: Error | null;
|
||||
logLoading: boolean;
|
||||
personaLabelsById: Record<string, string>;
|
||||
presenceLookup: PresenceLookup;
|
||||
onAddToChannel: (agent: ManagedAgent) => void;
|
||||
onDelete: (pubkey: string) => void;
|
||||
onMintToken: (pubkey: string, name: string) => void;
|
||||
onSelectLogAgent: (pubkey: string | null) => void;
|
||||
onStart: (pubkey: string) => void;
|
||||
onStop: (pubkey: string) => void;
|
||||
onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void;
|
||||
}) {
|
||||
const isActive = agent.status === "running" || agent.status === "deployed";
|
||||
const isLocal = agent.backend.type === "local";
|
||||
const runtimeSource =
|
||||
agent.backend.type === "local"
|
||||
? `ACP ${agent.acpCommand}`
|
||||
: `Provider ${agent.backend.id}`;
|
||||
const personaLabel = agent.personaId
|
||||
? (personaLabelsById[agent.personaId] ?? null)
|
||||
: null;
|
||||
const presenceStatus = presenceLookup[agent.pubkey.trim().toLowerCase()];
|
||||
const processDetail =
|
||||
agent.pid !== null
|
||||
? `PID ${agent.pid}`
|
||||
: agent.lastExitCode !== null
|
||||
? `Exit ${agent.lastExitCode}`
|
||||
: isLocal
|
||||
? "Ready to launch"
|
||||
: "Managed remotely";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border bg-card/70 transition-colors",
|
||||
isLogSelected
|
||||
? "border-primary/40 bg-primary/5 shadow-sm"
|
||||
: "border-border/70 hover:bg-muted/20",
|
||||
)}
|
||||
data-testid={`managed-agent-${agent.pubkey}`}
|
||||
>
|
||||
<div className="flex items-start gap-3 px-4 py-3">
|
||||
{isLocal ? (
|
||||
<button
|
||||
aria-expanded={isLogSelected}
|
||||
className="-m-1 min-w-0 flex-1 rounded-lg p-1 text-left transition-colors hover:bg-background/40"
|
||||
onClick={() =>
|
||||
onSelectLogAgent(isLogSelected ? null : agent.pubkey)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.8fr)_minmax(120px,0.8fr)_minmax(0,1.1fr)] lg:gap-4">
|
||||
<AgentSummary
|
||||
agent={agent}
|
||||
isExpandable
|
||||
isLogSelected={isLogSelected}
|
||||
personaLabel={personaLabel}
|
||||
presenceStatus={presenceStatus}
|
||||
/>
|
||||
<StatusBlock
|
||||
isActive={isActive}
|
||||
processDetail={processDetail}
|
||||
status={agent.status}
|
||||
/>
|
||||
<RuntimeBlock agent={agent} runtimeSource={runtimeSource} />
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.8fr)_minmax(120px,0.8fr)_minmax(0,1.1fr)] lg:gap-4">
|
||||
<AgentSummary
|
||||
agent={agent}
|
||||
isExpandable={false}
|
||||
isLogSelected={false}
|
||||
personaLabel={personaLabel}
|
||||
presenceStatus={presenceStatus}
|
||||
/>
|
||||
<StatusBlock
|
||||
isActive={isActive}
|
||||
processDetail={processDetail}
|
||||
status={agent.status}
|
||||
/>
|
||||
<RuntimeBlock agent={agent} runtimeSource={runtimeSource} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex shrink-0 items-start gap-2 lg:pt-0.5">
|
||||
<ModelPicker agent={agent} />
|
||||
<AgentActionsMenu
|
||||
agent={agent}
|
||||
isActionPending={isActionPending}
|
||||
isActive={isActive}
|
||||
onAddToChannel={onAddToChannel}
|
||||
onDelete={onDelete}
|
||||
onMintToken={onMintToken}
|
||||
onOpenLogs={(pubkey) => onSelectLogAgent(pubkey)}
|
||||
onStart={onStart}
|
||||
onStop={onStop}
|
||||
onToggleStartOnAppLaunch={onToggleStartOnAppLaunch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLocal && isLogSelected ? (
|
||||
<div
|
||||
className="border-t border-border/60 bg-background/60 px-4 py-4"
|
||||
data-testid="managed-agent-log-row"
|
||||
>
|
||||
<ManagedAgentLogPanel
|
||||
error={logError}
|
||||
isLoading={logLoading}
|
||||
logContent={logContent}
|
||||
selectedAgent={agent}
|
||||
variant="inline"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentSummary({
|
||||
agent,
|
||||
isExpandable,
|
||||
isLogSelected,
|
||||
personaLabel,
|
||||
presenceStatus,
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
isExpandable: boolean;
|
||||
isLogSelected: boolean;
|
||||
personaLabel: string | null;
|
||||
presenceStatus: PresenceStatus | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-start gap-3">
|
||||
{isExpandable ? (
|
||||
isLogSelected ? (
|
||||
<ChevronDown className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)
|
||||
) : (
|
||||
<span className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
)}
|
||||
{presenceStatus ? (
|
||||
<PresenceDot className="mt-1 shrink-0" status={presenceStatus} />
|
||||
) : (
|
||||
<span className="mt-1 h-2 w-2 shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="truncate font-medium text-foreground">{agent.name}</p>
|
||||
{personaLabel ? (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{personaLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<AgentOriginBadge agent={agent} />
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{truncatePubkey(agent.pubkey)}</span>
|
||||
{agent.backend.type === "local" ? (
|
||||
<span>
|
||||
{agent.startOnAppLaunch ? "Auto-start" : "Manual start"}
|
||||
</span>
|
||||
) : (
|
||||
<span>Remote deployment</span>
|
||||
)}
|
||||
{isExpandable ? (
|
||||
<span>
|
||||
{isLogSelected ? "Logs visible inline" : "Click to view logs"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBlock({
|
||||
isActive,
|
||||
processDetail,
|
||||
status,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
processDetail: string;
|
||||
status: ManagedAgent["status"];
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1 lg:pt-0.5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground lg:hidden">
|
||||
Status
|
||||
</p>
|
||||
<AgentStatusBadge isActive={isActive} status={status} />
|
||||
<p className="text-xs text-muted-foreground">{processDetail}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeBlock({
|
||||
agent,
|
||||
runtimeSource,
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
runtimeSource: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1 lg:pt-0.5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground lg:hidden">
|
||||
Runtime
|
||||
</p>
|
||||
<p className="truncate font-mono text-xs text-foreground">
|
||||
{agent.agentCommand}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{runtimeSource}</span>
|
||||
{agent.model ? <span>{agent.model}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentActionsMenu({
|
||||
agent,
|
||||
isActionPending,
|
||||
isActive,
|
||||
onAddToChannel,
|
||||
onDelete,
|
||||
onMintToken,
|
||||
onOpenLogs,
|
||||
onStart,
|
||||
onStop,
|
||||
onToggleStartOnAppLaunch,
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
isActionPending: boolean;
|
||||
isActive: boolean;
|
||||
onAddToChannel: (agent: ManagedAgent) => void;
|
||||
onDelete: (pubkey: string) => void;
|
||||
onMintToken: (pubkey: string, name: string) => void;
|
||||
onOpenLogs: (pubkey: string) => void;
|
||||
onStart: (pubkey: string) => void;
|
||||
onStop: (pubkey: string) => void;
|
||||
onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => 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()}
|
||||
>
|
||||
{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" />
|
||||
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>
|
||||
|
||||
{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)
|
||||
}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentOriginBadge({ agent }: { agent: ManagedAgent }) {
|
||||
return (
|
||||
<span className="rounded-full border border-border/70 bg-background/80 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{agent.backend.type === "local" ? "Local" : "Remote"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentStatusBadge({
|
||||
isActive,
|
||||
status,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
status: ManagedAgent["status"];
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em]",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status.replace(/_/g, " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,10 @@
|
||||
import {
|
||||
Clipboard,
|
||||
Ellipsis,
|
||||
FileText,
|
||||
KeyRound,
|
||||
Play,
|
||||
Plus,
|
||||
Power,
|
||||
Square,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import type { ManagedAgent, PresenceLookup } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { ModelPicker } from "./ModelPicker";
|
||||
import { truncatePubkey } from "./agentUi";
|
||||
import { ManagedAgentRow } from "./ManagedAgentRow";
|
||||
|
||||
export function ManagedAgentsSection({
|
||||
actionErrorMessage,
|
||||
@@ -34,16 +13,20 @@ export function ManagedAgentsSection({
|
||||
error,
|
||||
isActionPending,
|
||||
isLoading,
|
||||
logContent,
|
||||
logError,
|
||||
logLoading,
|
||||
personaLabelsById,
|
||||
presenceLookup,
|
||||
onAddToChannel,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onMintToken,
|
||||
onSelectLogAgent,
|
||||
onStart,
|
||||
onStop,
|
||||
onToggleStartOnAppLaunch,
|
||||
onViewLogs,
|
||||
selectedLogAgentPubkey,
|
||||
}: {
|
||||
actionErrorMessage: string | null;
|
||||
actionNoticeMessage: string | null;
|
||||
@@ -51,16 +34,20 @@ export function ManagedAgentsSection({
|
||||
error: Error | null;
|
||||
isActionPending: boolean;
|
||||
isLoading: boolean;
|
||||
logContent: string | null;
|
||||
logError: Error | null;
|
||||
logLoading: boolean;
|
||||
personaLabelsById: Record<string, string>;
|
||||
presenceLookup: PresenceLookup;
|
||||
onAddToChannel: (agent: ManagedAgent) => void;
|
||||
onCreate: () => void;
|
||||
onDelete: (pubkey: string) => void;
|
||||
onMintToken: (pubkey: string, name: string) => void;
|
||||
onSelectLogAgent: (pubkey: string | null) => void;
|
||||
onStart: (pubkey: string) => void;
|
||||
onStop: (pubkey: string) => void;
|
||||
onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void;
|
||||
onViewLogs: (pubkey: string) => void;
|
||||
selectedLogAgentPubkey: string | null;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
@@ -78,9 +65,9 @@ export function ManagedAgentsSection({
|
||||
<Button
|
||||
aria-label="Create agent"
|
||||
onClick={onCreate}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -118,41 +105,31 @@ export function ManagedAgentsSection({
|
||||
) : null}
|
||||
|
||||
{!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}
|
||||
personaLabelsById={personaLabelsById}
|
||||
presenceLookup={presenceLookup}
|
||||
onAddToChannel={onAddToChannel}
|
||||
onDelete={onDelete}
|
||||
onMintToken={onMintToken}
|
||||
onStart={onStart}
|
||||
onStop={onStop}
|
||||
onToggleStartOnAppLaunch={onToggleStartOnAppLaunch}
|
||||
onViewLogs={onViewLogs}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="space-y-2" data-testid="managed-agents-table">
|
||||
{agents.map((agent) => (
|
||||
<ManagedAgentRow
|
||||
agent={agent}
|
||||
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}
|
||||
presenceLookup={presenceLookup}
|
||||
onAddToChannel={onAddToChannel}
|
||||
onDelete={onDelete}
|
||||
onMintToken={onMintToken}
|
||||
onSelectLogAgent={onSelectLogAgent}
|
||||
onStart={onStart}
|
||||
onStop={onStop}
|
||||
onToggleStartOnAppLaunch={onToggleStartOnAppLaunch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -176,238 +153,3 @@ export function ManagedAgentsSection({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ManagedAgentRow({
|
||||
agent,
|
||||
isActionPending,
|
||||
personaLabelsById,
|
||||
presenceLookup,
|
||||
onAddToChannel,
|
||||
onDelete,
|
||||
onMintToken,
|
||||
onStart,
|
||||
onStop,
|
||||
onToggleStartOnAppLaunch,
|
||||
onViewLogs,
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
isActionPending: boolean;
|
||||
personaLabelsById: Record<string, string>;
|
||||
presenceLookup: PresenceLookup;
|
||||
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;
|
||||
}) {
|
||||
const isActive = agent.status === "running" || agent.status === "deployed";
|
||||
const personaLabel = agent.personaId
|
||||
? (personaLabelsById[agent.personaId] ?? null)
|
||||
: null;
|
||||
const presenceStatus = presenceLookup[agent.pubkey.trim().toLowerCase()];
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
"border-b border-border/60 last:border-b-0 hover:bg-muted/30",
|
||||
agent.backend.type === "local" && "cursor-pointer",
|
||||
)}
|
||||
data-testid={`managed-agent-${agent.pubkey}`}
|
||||
onClick={() => {
|
||||
if (agent.backend.type === "local") {
|
||||
onViewLogs(agent.pubkey);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{presenceStatus ? (
|
||||
<PresenceDot className="shrink-0" status={presenceStatus} />
|
||||
) : null}
|
||||
<p className="truncate font-medium text-foreground">{agent.name}</p>
|
||||
</div>
|
||||
{personaLabel ? (
|
||||
<p className="mt-1">
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{personaLabel}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
<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]",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{agent.status}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
className="px-4 py-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ModelPicker agent={agent} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{agent.agentCommand}</td>
|
||||
<td
|
||||
className="px-3 py-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AgentActionsMenu
|
||||
agent={agent}
|
||||
isActionPending={isActionPending}
|
||||
isActive={isActive}
|
||||
onAddToChannel={onAddToChannel}
|
||||
onDelete={onDelete}
|
||||
onMintToken={onMintToken}
|
||||
onStart={onStart}
|
||||
onStop={onStop}
|
||||
onToggleStartOnAppLaunch={onToggleStartOnAppLaunch}
|
||||
onViewLogs={onViewLogs}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentActionsMenu({
|
||||
agent,
|
||||
isActionPending,
|
||||
isActive,
|
||||
onAddToChannel,
|
||||
onDelete,
|
||||
onMintToken,
|
||||
onStart,
|
||||
onStop,
|
||||
onToggleStartOnAppLaunch,
|
||||
onViewLogs,
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
isActionPending: boolean;
|
||||
isActive: 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()}
|
||||
>
|
||||
{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" />
|
||||
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>
|
||||
|
||||
{agent.backend.type === "local" ? (
|
||||
<DropdownMenuItem onClick={() => onViewLogs(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)
|
||||
}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Pencil, Play, X } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Pencil, Play, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
@@ -34,9 +34,6 @@ export function WorkflowDetailPanel({
|
||||
|
||||
const workflow = workflowQuery.data;
|
||||
const runs = runsQuery.data ?? [];
|
||||
const selectedRun = selectedRunId
|
||||
? (runs.find((r) => r.id === selectedRunId) ?? null)
|
||||
: null;
|
||||
const approvalsQuery = useRunApprovalsQuery(workflowId, selectedRunId);
|
||||
const workflowDescription = workflow
|
||||
? getWorkflowDescription(workflow.definition)
|
||||
@@ -136,45 +133,101 @@ export function WorkflowDetailPanel({
|
||||
<p className="text-sm text-muted-foreground">No runs yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{runs.map((run) => (
|
||||
<button
|
||||
className={`w-full rounded-md border px-3 py-2 text-left text-xs transition-colors hover:bg-muted/50 ${
|
||||
selectedRunId === run.id
|
||||
? "border-primary bg-primary/5"
|
||||
: ""
|
||||
}`}
|
||||
key={run.id}
|
||||
onClick={() =>
|
||||
setSelectedRunId(
|
||||
selectedRunId === run.id ? null : run.id,
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono">{run.id.slice(0, 8)}</span>
|
||||
<RunStatusBadge status={run.status} />
|
||||
{runs.map((run) => {
|
||||
const isSelected = selectedRunId === run.id;
|
||||
const duration = formatRunDuration(
|
||||
run.startedAt,
|
||||
run.completedAt,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`overflow-hidden rounded-xl border bg-card/70 transition-colors ${
|
||||
isSelected
|
||||
? "border-primary/40 bg-primary/5 shadow-sm"
|
||||
: "border-border/70 hover:bg-muted/20"
|
||||
}`}
|
||||
key={run.id}
|
||||
>
|
||||
<button
|
||||
aria-expanded={isSelected}
|
||||
className="w-full px-4 py-3 text-left"
|
||||
data-testid={
|
||||
isSelected ? "workflow-selected-run" : undefined
|
||||
}
|
||||
onClick={() =>
|
||||
setSelectedRunId(isSelected ? null : run.id)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{isSelected ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate font-mono text-xs font-medium">
|
||||
{run.id.slice(0, 8)}
|
||||
</span>
|
||||
<RunStatusBadge status={run.status} />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 pl-6 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{new Date(
|
||||
run.createdAt * 1000,
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
<span>
|
||||
{run.executionTrace.length}{" "}
|
||||
{run.executionTrace.length === 1
|
||||
? "step"
|
||||
: "steps"}
|
||||
</span>
|
||||
{duration ? <span>{duration}</span> : null}
|
||||
{run.currentStep !== null ? (
|
||||
<span>
|
||||
Current step {run.currentStep + 1}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{run.errorMessage ? (
|
||||
<p className="mt-2 break-words pl-6 text-xs text-destructive">
|
||||
{run.errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isSelected ? (
|
||||
<div className="border-t border-border/60 bg-background/60 px-4 py-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
<span>Execution Trace</span>
|
||||
{approvalsQuery.isFetching ? (
|
||||
<span className="text-[10px] tracking-[0.12em] text-muted-foreground/80">
|
||||
Refreshing approvals...
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{approvalsQuery.error instanceof Error ? (
|
||||
<p className="mb-3 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{approvalsQuery.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
<WorkflowRunTrace
|
||||
approvals={approvalsQuery.data}
|
||||
run={run}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
{new Date(run.createdAt * 1000).toLocaleString()}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedRun ? (
|
||||
<div>
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Execution Trace
|
||||
</h4>
|
||||
<WorkflowRunTrace
|
||||
approvals={approvalsQuery.data}
|
||||
run={selectedRun}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : workflowQuery.isError ? (
|
||||
<div className="flex h-32 flex-col items-center justify-center gap-2">
|
||||
@@ -190,6 +243,20 @@ export function WorkflowDetailPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function formatRunDuration(
|
||||
startedAt: number | null,
|
||||
completedAt: number | null,
|
||||
) {
|
||||
if (startedAt === null || completedAt === null) return null;
|
||||
const seconds = completedAt - startedAt;
|
||||
if (seconds < 1) return `${Math.round(seconds * 1000)}ms`;
|
||||
return `${seconds.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function formatStatusLabel(status: string) {
|
||||
return status.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function RunStatusBadge({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
active: "bg-green-500/15 text-green-500",
|
||||
@@ -205,9 +272,9 @@ function RunStatusBadge({ status }: { status: string }) {
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${colors[status] ?? colors.pending}`}
|
||||
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] ${colors[status] ?? colors.pending}`}
|
||||
>
|
||||
{status}
|
||||
{formatStatusLabel(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -233,6 +233,7 @@ export function WorkflowFormBuilder({
|
||||
{mode === "yaml" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Textarea
|
||||
autoCapitalize="off"
|
||||
className="min-h-[240px] resize-y font-mono text-xs"
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
@@ -264,6 +265,7 @@ export function WorkflowFormBuilder({
|
||||
Description (optional)
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
autoCapitalize="off"
|
||||
className="min-h-[72px] resize-y text-sm"
|
||||
disabled={disabled}
|
||||
id="wf-description"
|
||||
|
||||
@@ -8,6 +8,10 @@ type WorkflowRunTraceProps = {
|
||||
approvals?: WorkflowApproval[];
|
||||
};
|
||||
|
||||
function formatStatusLabel(status: string) {
|
||||
return status.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function StepStatusIcon({ status }: { status: string }) {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
@@ -37,14 +41,14 @@ export function WorkflowRunTrace({
|
||||
}: WorkflowRunTraceProps) {
|
||||
if (run.executionTrace.length === 0) {
|
||||
return (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
<p className="rounded-xl border border-dashed border-border/70 bg-background/60 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
No steps recorded yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1" data-testid="workflow-run-trace">
|
||||
<div className="space-y-3" data-testid="workflow-run-trace">
|
||||
{run.executionTrace.map((step) => {
|
||||
const duration = formatDuration(step.startedAt, step.completedAt);
|
||||
const pendingApproval = approvals.find(
|
||||
@@ -52,14 +56,17 @@ export function WorkflowRunTrace({
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={step.stepId}>
|
||||
<div className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted/50">
|
||||
<div
|
||||
className="rounded-xl border border-border/60 bg-background/80 p-3 shadow-sm"
|
||||
key={step.stepId}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<StepStatusIcon status={step.status} />
|
||||
<span className="flex-1 truncate font-mono text-xs">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs font-medium">
|
||||
{step.stepId}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{step.status}
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{formatStatusLabel(step.status)}
|
||||
</span>
|
||||
{duration ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -68,17 +75,30 @@ export function WorkflowRunTrace({
|
||||
) : null}
|
||||
</div>
|
||||
{Object.keys(step.output).length > 0 ? (
|
||||
<pre className="ml-8 max-h-24 overflow-auto rounded bg-muted/30 px-2 py-1 font-mono text-xs text-muted-foreground">
|
||||
{JSON.stringify(step.output, null, 2)}
|
||||
</pre>
|
||||
<div className="mt-3">
|
||||
<p className="mb-1 text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Output
|
||||
</p>
|
||||
<pre className="max-h-32 overflow-auto rounded-lg bg-muted/40 px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||
{JSON.stringify(step.output, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
{step.error ? (
|
||||
<pre className="ml-8 max-h-24 overflow-auto rounded bg-red-500/10 px-2 py-1 font-mono text-xs text-red-400">
|
||||
{step.error}
|
||||
</pre>
|
||||
<div className="mt-3">
|
||||
<p className="mb-1 text-[11px] font-medium uppercase tracking-[0.16em] text-red-400">
|
||||
Error
|
||||
</p>
|
||||
<pre className="max-h-32 overflow-auto rounded-lg bg-red-500/10 px-3 py-2 font-mono text-xs text-red-400">
|
||||
{step.error}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
{pendingApproval ? (
|
||||
<div className="ml-8 mt-1">
|
||||
<div className="mt-3">
|
||||
<p className="mb-2 text-[11px] font-medium uppercase tracking-[0.16em] text-amber-600">
|
||||
Pending approval
|
||||
</p>
|
||||
<WorkflowApprovalCard approval={pendingApproval} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -76,6 +76,7 @@ function StepConfigFields({
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-text`}>Message text</FieldLabel>
|
||||
<Textarea
|
||||
autoCapitalize="off"
|
||||
className="min-h-[60px] resize-y text-xs"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-text`}
|
||||
@@ -133,6 +134,7 @@ function StepConfigFields({
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-text`}>Message text</FieldLabel>
|
||||
<Textarea
|
||||
autoCapitalize="off"
|
||||
className="min-h-[60px] resize-y text-xs"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-text`}
|
||||
@@ -192,6 +194,7 @@ function StepConfigFields({
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-body`}>Body (optional)</FieldLabel>
|
||||
<Textarea
|
||||
autoCapitalize="off"
|
||||
className="min-h-[60px] resize-y font-mono text-xs"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-body`}
|
||||
@@ -224,6 +227,7 @@ function StepConfigFields({
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-message`}>Message</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-message`}
|
||||
onChange={(event) =>
|
||||
@@ -238,6 +242,7 @@ function StepConfigFields({
|
||||
Timeout (optional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-timeout`}
|
||||
onChange={(event) =>
|
||||
@@ -272,6 +277,7 @@ function StepConfigFields({
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-topic`}>Topic</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-topic`}
|
||||
onChange={(event) =>
|
||||
@@ -341,6 +347,7 @@ export function WorkflowStepCard({
|
||||
Step name (optional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-name`}
|
||||
onChange={(event) =>
|
||||
@@ -379,6 +386,7 @@ export function WorkflowStepCard({
|
||||
Timeout seconds (optional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-timeout-secs`}
|
||||
inputMode="numeric"
|
||||
|
||||
@@ -1014,9 +1014,43 @@ type MockWorkflow = {
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
type RawWorkflowTraceEntry = {
|
||||
step_id: string;
|
||||
status: string;
|
||||
output?: Record<string, unknown>;
|
||||
started_at?: number | null;
|
||||
completed_at?: number | null;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
type RawWorkflowRun = {
|
||||
id: string;
|
||||
workflow_id: string;
|
||||
status:
|
||||
| "pending"
|
||||
| "running"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "waiting_approval";
|
||||
current_step: number | null;
|
||||
execution_trace: RawWorkflowTraceEntry[];
|
||||
started_at: number | null;
|
||||
completed_at: number | null;
|
||||
error_message: string | null;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
const mockWorkflows: MockWorkflow[] = [];
|
||||
let mockWorkflowRuns: RawWorkflowRun[] = [];
|
||||
let mockWorkflowIdCounter = 0;
|
||||
|
||||
function resetMockWorkflows() {
|
||||
mockWorkflows.length = 0;
|
||||
mockWorkflowRuns = [];
|
||||
mockWorkflowIdCounter = 0;
|
||||
}
|
||||
|
||||
function parseWorkflowDefinition(
|
||||
yamlDefinition: string,
|
||||
): Record<string, unknown> {
|
||||
@@ -1095,20 +1129,91 @@ function handleDeleteWorkflow(args: { workflowId: string }) {
|
||||
const index = mockWorkflows.findIndex((w) => w.id === args.workflowId);
|
||||
if (index === -1) throw new Error(`Workflow ${args.workflowId} not found`);
|
||||
mockWorkflows.splice(index, 1);
|
||||
mockWorkflowRuns = mockWorkflowRuns.filter(
|
||||
(run) => run.workflow_id !== args.workflowId,
|
||||
);
|
||||
}
|
||||
|
||||
function buildMockWorkflowRun(workflow: MockWorkflow): RawWorkflowRun {
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
const rawSteps = Array.isArray(workflow.definition.steps)
|
||||
? workflow.definition.steps
|
||||
: [];
|
||||
const executionTrace = rawSteps.map((candidate, index) => {
|
||||
const step =
|
||||
candidate && typeof candidate === "object"
|
||||
? (candidate as Record<string, unknown>)
|
||||
: {};
|
||||
const startedAt = createdAt + index;
|
||||
const completedAt = startedAt + 1;
|
||||
const output: Record<string, unknown> = {};
|
||||
|
||||
if (typeof step.action === "string") {
|
||||
output.action = step.action;
|
||||
}
|
||||
if (typeof step.name === "string" && step.name.trim().length > 0) {
|
||||
output.name = step.name;
|
||||
}
|
||||
if (typeof step.text === "string" && step.text.trim().length > 0) {
|
||||
output.preview = step.text;
|
||||
}
|
||||
|
||||
return {
|
||||
step_id:
|
||||
typeof step.id === "string" && step.id.trim().length > 0
|
||||
? step.id
|
||||
: `step_${index + 1}`,
|
||||
status: "completed",
|
||||
output,
|
||||
started_at: startedAt,
|
||||
completed_at: completedAt,
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
|
||||
const startedAt =
|
||||
executionTrace.length > 0
|
||||
? (executionTrace[0].started_at ?? createdAt)
|
||||
: createdAt;
|
||||
const lastTraceEntry = executionTrace[executionTrace.length - 1];
|
||||
const completedAt =
|
||||
executionTrace.length > 0
|
||||
? (lastTraceEntry?.completed_at ?? createdAt)
|
||||
: createdAt;
|
||||
|
||||
return {
|
||||
id: `mock-run-${Date.now()}`,
|
||||
workflow_id: workflow.id,
|
||||
status: "completed",
|
||||
current_step: null,
|
||||
execution_trace: executionTrace,
|
||||
started_at: startedAt,
|
||||
completed_at: completedAt,
|
||||
error_message: null,
|
||||
created_at: createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function handleTriggerWorkflow(args: { workflowId: string }) {
|
||||
const workflow = mockWorkflows.find((w) => w.id === args.workflowId);
|
||||
if (!workflow) throw new Error(`Workflow ${args.workflowId} not found`);
|
||||
const run = buildMockWorkflowRun(workflow);
|
||||
mockWorkflowRuns = [run, ...mockWorkflowRuns];
|
||||
return {
|
||||
run_id: `mock-run-${Date.now()}`,
|
||||
run_id: run.id,
|
||||
workflow_id: workflow.id,
|
||||
status: "pending",
|
||||
status: run.status,
|
||||
};
|
||||
}
|
||||
|
||||
function handleGetWorkflowRuns(_args: { workflowId: string }) {
|
||||
return [];
|
||||
function handleGetWorkflowRuns(args: {
|
||||
limit?: number | null;
|
||||
workflowId: string;
|
||||
}) {
|
||||
const runs = mockWorkflowRuns.filter(
|
||||
(run) => run.workflow_id === args.workflowId,
|
||||
);
|
||||
return args.limit ? runs.slice(0, args.limit) : runs;
|
||||
}
|
||||
|
||||
function handleGetRunApprovals(_args: { workflowId: string; runId: string }) {
|
||||
@@ -3364,6 +3469,7 @@ export function maybeInstallE2eTauriMocks() {
|
||||
resetMockTokens(config);
|
||||
resetMockManagedAgents();
|
||||
resetMockPersonas();
|
||||
resetMockWorkflows();
|
||||
mockWindows("main");
|
||||
window.__SPROUT_E2E_COMMANDS__ = [];
|
||||
window.__SPROUT_E2E_WEBVIEW_ZOOM__ = 1;
|
||||
|
||||
@@ -96,12 +96,12 @@ test("create agent supports parallelism and system prompt overrides", async ({
|
||||
await expect(page.getByTestId("managed-agents-table")).toContainText(
|
||||
agentName,
|
||||
);
|
||||
await expect(page.getByTestId("managed-agent-log-content")).toContainText(
|
||||
"parallelism=3",
|
||||
);
|
||||
await expect(page.getByTestId("managed-agent-log-content")).toContainText(
|
||||
"system prompt override configured",
|
||||
);
|
||||
const inlineLog = page
|
||||
.getByTestId("managed-agents-table")
|
||||
.getByTestId("managed-agent-log-content");
|
||||
|
||||
await expect(inlineLog).toContainText("parallelism=3");
|
||||
await expect(inlineLog).toContainText("system prompt override configured");
|
||||
});
|
||||
|
||||
test("opens a mocked channel from the home feed", async ({ page }) => {
|
||||
|
||||
@@ -80,6 +80,24 @@ test("creates a workflow via the form builder", async ({ page }) => {
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(workflowName);
|
||||
});
|
||||
|
||||
test("disables autocapitalization in the workflow form", async ({ page }) => {
|
||||
await navigateToWorkflows(page);
|
||||
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
|
||||
await expect(dialog.getByLabel("Workflow name")).toHaveAttribute(
|
||||
"autocapitalize",
|
||||
"off",
|
||||
);
|
||||
|
||||
await dialog.getByRole("button", { name: "Add step" }).click();
|
||||
await expect(dialog.getByLabel("Step name (optional)")).toHaveAttribute(
|
||||
"autocapitalize",
|
||||
"off",
|
||||
);
|
||||
});
|
||||
|
||||
test("captures disabled diff workflows in the list UI", async ({ page }) => {
|
||||
const workflowName = `diff_workflow_${Date.now()}`;
|
||||
const description = "Watches diff events for src/ changes";
|
||||
@@ -222,4 +240,13 @@ test("triggers a workflow from the detail panel", async ({ page }) => {
|
||||
.getByTestId("workflow-detail-panel")
|
||||
.getByRole("button", { name: "Trigger" }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("workflow-detail-panel")
|
||||
.getByTestId("workflow-selected-run"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("workflow-detail-panel").getByTestId("workflow-run-trace"),
|
||||
).toContainText("step_1");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user