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;
|
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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user