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:
9
.changeset/agent-card-list-ux.md
Normal file
9
.changeset/agent-card-list-ux.md
Normal 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.
|
||||
@@ -694,13 +694,27 @@
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.agent-card-actions .btn {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-card-details-btn {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -675,11 +675,31 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
}, [isMobileViewport, openAgentDetail]);
|
||||
|
||||
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 {
|
||||
await startAgentRun(agentId, projectId, { source: "on_demand", triggerDetail: "Triggered from dashboard" });
|
||||
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) {
|
||||
setOptimisticStateOverrides((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
});
|
||||
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 isUpdatingHeartbeat = updatingHeartbeatAgentId === agent.id;
|
||||
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-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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="agent-info">
|
||||
{editingRoleForAgent === agent.id ? (
|
||||
<select
|
||||
ref={roleSelectRef}
|
||||
@@ -1420,7 +1457,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
title={`View details for ${agent.name}`}
|
||||
aria-label={`View details for ${agent.name}`}
|
||||
>
|
||||
View Details
|
||||
Details
|
||||
</button>
|
||||
{(agent.state === "idle" || agent.state === "terminated" || agent.state === "paused") && (
|
||||
<button
|
||||
|
||||
@@ -619,7 +619,7 @@ describe("AgentsView", () => {
|
||||
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 () => {
|
||||
@@ -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} />);
|
||||
|
||||
await waitFor(() => {
|
||||
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"),
|
||||
) as HTMLElement | undefined;
|
||||
expect(clickableIdentity).toBeTruthy();
|
||||
expect(clickableCard).toBeTruthy();
|
||||
|
||||
fireEvent.click(clickableIdentity!);
|
||||
fireEvent.click(clickableCard!);
|
||||
|
||||
await waitFor(() => {
|
||||
const detail = screen.getByTestId("agent-detail-view");
|
||||
|
||||
Reference in New Issue
Block a user