feat(dashboard): make agent card row click-anywhere and Run Now optimistic

- Run Now now optimistically stamps the agent's state to "running" before
  the startAgentRun API call so the card reacts immediately. Rolls back on
  error, mirroring the handleStateChange pattern.
- Whole .agent-card body is clickable (role=button, Enter/Space, focus ring)
  and bails when the click landed on an action button, select, or the
  role-icon so those keep their dedicated behaviors.
- Renamed the card's "View Details" button to "Details" and switched
  .agent-card-actions to flex-wrap: nowrap so Run Now / Pause / Details
  stay on one row regardless of card width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-05 19:18:11 -07:00
parent d47501feeb
commit 7d41271601
4 changed files with 83 additions and 23 deletions

View File

@@ -0,0 +1,9 @@
---
"@runfusion/fusion": patch
---
Three small UX fixes on the agent list card.
- **Optimistic Run Now**: clicking the Run Now button now flips the card's state badge to `running` immediately. The `startAgentRun` API call can take several seconds, and the prior code awaited it before any visual feedback, leaving users unsure whether the click registered. Mirrors the existing `handleStateChange` pattern — stamp the override, await the API, refresh on success, roll back on failure.
- **Whole-card clickable**: the entire `.agent-card` body opens the agent detail view, not just the name/icon area. Clicks on action buttons (Run Now, Pause, Details, Delete), the role-edit select, and the role-icon button keep their dedicated behaviors via a target check that bails on interactive descendants. `role="button"`, `tabIndex`, and Enter/Space handling preserve keyboard access; a `--focus-ring` outline shows the focus state.
- **Single-row card actions**: renamed "View Details" → "Details" and switched `.agent-card-actions` to `flex-wrap: nowrap` with per-button `flex-shrink: 0; white-space: nowrap` so Run Now / Pause / Details stay on one row regardless of card width.

View File

@@ -694,13 +694,27 @@
display: flex; display: flex;
gap: var(--space-sm); gap: var(--space-sm);
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: nowrap;
}
.agent-card-actions .btn {
flex-shrink: 0;
white-space: nowrap;
} }
.agent-card-details-btn { .agent-card-details-btn {
margin-left: auto; margin-left: auto;
} }
.agent-card--clickable {
cursor: pointer;
}
.agent-card--clickable:focus-visible {
outline: 2px solid var(--focus-ring, var(--state-active-border));
outline-offset: 2px;
}
.agent-empty { .agent-empty {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -675,11 +675,31 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
}, [isMobileViewport, openAgentDetail]); }, [isMobileViewport, openAgentDetail]);
const handleRunHeartbeat = async (agentId: string, agentName: string) => { const handleRunHeartbeat = async (agentId: string, agentName: string) => {
// Optimistic state flip: the API call can take several seconds before the
// backend transitions the agent to running, and the user clicking "Run
// Now" reasonably expects the card to react immediately. We mirror the
// pattern handleStateChange uses: stamp the override, await the API,
// refetch on success, roll back on failure.
setOptimisticStateOverrides((prev) => {
const next = new Map(prev);
next.set(agentId, "running");
return next;
});
try { try {
await startAgentRun(agentId, projectId, { source: "on_demand", triggerDetail: "Triggered from dashboard" }); await startAgentRun(agentId, projectId, { source: "on_demand", triggerDetail: "Triggered from dashboard" });
addToast(`Heartbeat run started for ${agentName}`, "success"); addToast(`Heartbeat run started for ${agentName}`, "success");
void loadAgents(); await loadAgents();
setOptimisticStateOverrides((prev) => {
const next = new Map(prev);
next.delete(agentId);
return next;
});
} catch (err) { } catch (err) {
setOptimisticStateOverrides((prev) => {
const next = new Map(prev);
next.delete(agentId);
return next;
});
addToast(`Failed to start heartbeat run: ${getErrorMessage(err)}`, "error"); addToast(`Failed to start heartbeat run: ${getErrorMessage(err)}`, "error");
} }
}; };
@@ -1120,22 +1140,39 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const heartbeatOptions = getHeartbeatIntervalOptions(configuredIntervalMs); const heartbeatOptions = getHeartbeatIntervalOptions(configuredIntervalMs);
const isUpdatingHeartbeat = updatingHeartbeatAgentId === agent.id; const isUpdatingHeartbeat = updatingHeartbeatAgentId === agent.id;
return ( return (
<div key={agent.id} className={`agent-card ${stateCardClass}${selectedAgentId === agent.id ? " agent-card--selected" : ""}`}> <div
key={agent.id}
className={`agent-card agent-card--clickable ${stateCardClass}${selectedAgentId === agent.id ? " agent-card--selected" : ""}`}
onClick={(e) => {
// Open detail when the user clicks the card body, but
// bail when the click landed on an interactive
// descendant (action buttons, the role-edit select,
// the role-icon button) so those keep their dedicated
// behaviors instead of double-firing. Use currentTarget
// as the boundary so the card's own role="button" is
// not treated as an interactive descendant.
const target = e.target as HTMLElement;
if (target === e.currentTarget) {
openAgentDetail(agent.id);
return;
}
const interactive = target.closest('button, select, input, [role="button"]');
if (interactive && interactive !== e.currentTarget) return;
openAgentDetail(agent.id);
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
if (e.key === " ") e.preventDefault();
openAgentDetail(agent.id);
}
}}
aria-label={`Open details for ${agent.name}`}
>
<div className="agent-card-header"> <div className="agent-card-header">
<div <div className="agent-info">
className="agent-info agent-info--clickable"
onClick={() => openAgentDetail(agent.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (e.key === " ") {
e.preventDefault();
}
openAgentDetail(agent.id);
}
}}
>
{editingRoleForAgent === agent.id ? ( {editingRoleForAgent === agent.id ? (
<select <select
ref={roleSelectRef} ref={roleSelectRef}
@@ -1420,7 +1457,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
title={`View details for ${agent.name}`} title={`View details for ${agent.name}`}
aria-label={`View details for ${agent.name}`} aria-label={`View details for ${agent.name}`}
> >
View Details Details
</button> </button>
{(agent.state === "idle" || agent.state === "terminated" || agent.state === "paused") && ( {(agent.state === "idle" || agent.state === "terminated" || agent.state === "paused") && (
<button <button

View File

@@ -619,7 +619,7 @@ describe("AgentsView", () => {
expect(screen.getByRole("button", { name: "View details for Test Agent 2" })).toBeTruthy(); expect(screen.getByRole("button", { name: "View details for Test Agent 2" })).toBeTruthy();
}); });
expect(screen.getAllByText("View Details").length).toBeGreaterThanOrEqual(4); expect(screen.getAllByText("Details").length).toBeGreaterThanOrEqual(4);
}); });
it("opens matching detail view when clicking View Details button", async () => { it("opens matching detail view when clicking View Details button", async () => {
@@ -659,19 +659,19 @@ describe("AgentsView", () => {
}); });
}); });
it("keeps clickable identity area behavior for opening detail view", async () => { it("opens detail view when clicking anywhere on the agent card body", async () => {
render(<AgentsView addToast={mockAddToast} />); render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByText("Test Agent 1").length).toBeGreaterThan(0); expect(screen.getAllByText("Test Agent 1").length).toBeGreaterThan(0);
}); });
const clickableIdentity = Array.from(document.querySelectorAll(".agent-info--clickable")).find((element) => const clickableCard = Array.from(document.querySelectorAll(".agent-card--clickable")).find((element) =>
element.textContent?.includes("Test Agent 1"), element.textContent?.includes("Test Agent 1"),
) as HTMLElement | undefined; ) as HTMLElement | undefined;
expect(clickableIdentity).toBeTruthy(); expect(clickableCard).toBeTruthy();
fireEvent.click(clickableIdentity!); fireEvent.click(clickableCard!);
await waitFor(() => { await waitFor(() => {
const detail = screen.getByTestId("agent-detail-view"); const detail = screen.getByTestId("agent-detail-view");