[codex] Polish workflow and agent run history UX (#253)

This commit is contained in:
Wes
2026-04-06 18:55:30 -07:00
committed by GitHub
parent 67706c93be
commit 9b0bba2810
11 changed files with 801 additions and 378 deletions

View File

@@ -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>

View File

@@ -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."}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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"

View File

@@ -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}

View File

@@ -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"

View File

@@ -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;

View File

@@ -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 }) => {

View File

@@ -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");
});