FN-8625: remove heartbeat controls from agent org charts

Keep org charts read-only for heartbeat configuration while preserving controls on board and list surfaces.

- Remove heartbeat toggle props, markup, and styling from org chart nodes
- Cover both org chart layouts and board heartbeat availability with regression tests
- Add a patch changeset for the dashboard behavior fix

Files changed:
 .../fn-8625-remove-org-chart-heartbeat-toggle.md     |  7 +++++++
 packages/dashboard/app/components/AgentsView.css     |  7 -------
 packages/dashboard/app/components/AgentsView.tsx     | 14 +++++---------
 .../__tests__/AgentsView.orgchart.test.tsx           | 20 ++++++++++++++++++++
 .../app/components/__tests__/AgentsView.test.tsx     | 20 +++++++++++---------
 5 files changed, 43 insertions(+), 25 deletions(-)

Fusion-Task-Id: FN-8625

Fusion-Task-Lineage: 454c05e6-fe31-486b-975a-80ba5e8c518c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-26 19:18:53 -07:00
parent 89b4621a93
commit d3c1955866
5 changed files with 43 additions and 25 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Remove the heartbeat enable/disable button from agent org chart nodes.
category: fix
dev: HeartbeatToggle remains in board view; OrgChartNode no longer receives heartbeat props.

View File

@@ -1235,13 +1235,6 @@ AgentsView uses the measured SVG overlay as the single connector system so paren
font-size: var(--space-md); font-size: var(--space-md);
} }
/* FNXC:AgentHeartbeatControls 2026-07-23-13:15: Keep the org action outside the card's button-like detail target so keyboard users never encounter nested interactive controls. */
.org-chart-node__actions {
display: flex;
justify-content: center;
margin-top: var(--space-xs);
}
.agent-heartbeat-toggle { .agent-heartbeat-toggle {
min-height: var(--touch-target-min-size); min-height: var(--touch-target-min-size);
} }

View File

@@ -180,7 +180,10 @@ type OrgChartTransform = { scale: number; x: number; y: number };
/* /*
FNXC:AgentHeartbeatControls 2026-07-23-13:10: FNXC:AgentHeartbeatControls 2026-07-23-13:10:
List, board, and org-chart cards use one explicit heartbeat action. It changes only runtimeConfig.enabled through the preserved payload helper; lifecycle pause/resume remains a separate control. List and board cards use one explicit heartbeat action. It changes only runtimeConfig.enabled through the preserved payload helper; lifecycle pause/resume remains a separate control.
FNXC:AgentHeartbeatControls 2026-07-26-19:07:
FN-8625 makes the org chart intentionally read-only for heartbeat configuration. Enable and disable actions remain available through board, list, and bulk-control surfaces.
*/ */
function HeartbeatToggle({ agent, pending, onToggle }: { agent: Agent; pending: boolean; onToggle: (agent: Agent) => void }) { function HeartbeatToggle({ agent, pending, onToggle }: { agent: Agent; pending: boolean; onToggle: (agent: Agent) => void }) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
@@ -210,15 +213,13 @@ function HeartbeatToggle({ agent, pending, onToggle }: { agent: Agent; pending:
type OrgChartNodeProps = { type OrgChartNodeProps = {
node: OrgTreeNode; node: OrgTreeNode;
onSelect: (id: string) => void; onSelect: (id: string) => void;
onToggleHeartbeat: (agent: Agent) => void;
isHeartbeatPending: (agentId: string) => boolean;
getHealthStatus: (agent: Agent) => AgentHealthStatus; getHealthStatus: (agent: Agent) => AgentHealthStatus;
selectedAgentId: string | null; selectedAgentId: string | null;
registerNodeElement: (id: string, element: HTMLDivElement | null) => void; registerNodeElement: (id: string, element: HTMLDivElement | null) => void;
linksRef: MutableRefObject<OrgChartLink[]>; linksRef: MutableRefObject<OrgChartLink[]>;
}; };
function OrgChartNode({ node, onSelect, onToggleHeartbeat, isHeartbeatPending, getHealthStatus, selectedAgentId, registerNodeElement, linksRef }: OrgChartNodeProps) { function OrgChartNode({ node, onSelect, getHealthStatus, selectedAgentId, registerNodeElement, linksRef }: OrgChartNodeProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const { agent, children } = node; const { agent, children } = node;
const health = getHealthStatus(agent); const health = getHealthStatus(agent);
@@ -258,7 +259,6 @@ function OrgChartNode({ node, onSelect, onToggleHeartbeat, isHeartbeatPending, g
</span> </span>
</div> </div>
</div> </div>
<div className="org-chart-node__actions"><HeartbeatToggle agent={agent} pending={isHeartbeatPending(agent.id)} onToggle={onToggleHeartbeat} /></div>
{children.length > 0 && ( {children.length > 0 && (
<div className="org-chart-children" role="group" aria-label={t("agents.orgChartEmployees", "{{name}} employees", { name: agent.name })}> <div className="org-chart-children" role="group" aria-label={t("agents.orgChartEmployees", "{{name}} employees", { name: agent.name })}>
{children.map((child) => { {children.map((child) => {
@@ -268,8 +268,6 @@ function OrgChartNode({ node, onSelect, onToggleHeartbeat, isHeartbeatPending, g
key={child.agent.id} key={child.agent.id}
node={child} node={child}
onSelect={onSelect} onSelect={onSelect}
onToggleHeartbeat={onToggleHeartbeat}
isHeartbeatPending={isHeartbeatPending}
getHealthStatus={getHealthStatus} getHealthStatus={getHealthStatus}
selectedAgentId={selectedAgentId} selectedAgentId={selectedAgentId}
registerNodeElement={registerNodeElement} registerNodeElement={registerNodeElement}
@@ -1874,8 +1872,6 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
key={node.agent.id} key={node.agent.id}
node={node} node={node}
onSelect={handleOrgChartNodeSelect} onSelect={handleOrgChartNodeSelect}
onToggleHeartbeat={(agent) => void handleHeartbeatEnabledChange(agent, !isAgentHeartbeatEnabled(agent))}
isHeartbeatPending={(agentId) => isBulkHeartbeatMutationRunning || heartbeatMutationAgentIds.has(agentId)}
getHealthStatus={getHealthStatus} getHealthStatus={getHealthStatus}
selectedAgentId={selectedOrgChartAgentId} selectedAgentId={selectedOrgChartAgentId}
registerNodeElement={registerOrgChartNodeElement} registerNodeElement={registerOrgChartNodeElement}

View File

@@ -147,6 +147,26 @@ describe("AgentsView org chart interactions", () => {
}); });
}); });
it("renders heartbeat controls nowhere in either org-chart layout", async () => {
const { container } = render(<AgentsView addToast={vi.fn()} />);
fireEvent.click(await screen.findByLabelText("Org Chart view"));
const assertHeartbeatControlsAreAbsent = () => {
const chart = screen.getByTestId("agent-org-chart");
expect(container.querySelectorAll(".org-chart-node__actions")).toHaveLength(0);
expect(Array.from(chart.querySelectorAll("button")).filter((button) => /Disable heartbeat|Enable heartbeat/i.test(button.getAttribute("aria-label") ?? button.textContent ?? ""))).toHaveLength(0);
expect(chart.querySelectorAll(".org-chart-node button")).toHaveLength(0);
};
await screen.findByText("Eng C");
assertHeartbeatControlsAreAbsent();
const layoutToggle = screen.getByTestId("agent-org-chart-layout-toggle");
fireEvent.click(layoutToggle.querySelector<HTMLButtonElement>('[data-layout-value="vertical"]')!);
await waitFor(() => expect(screen.getByTestId("agent-org-chart")).toHaveAttribute("data-layout-mode", "vertical"));
assertHeartbeatControlsAreAbsent();
});
it("does not render connector paths for empty or single-root org chart data states", async () => { it("does not render connector paths for empty or single-root org chart data states", async () => {
mockFetchOrgTree.mockResolvedValueOnce([]); mockFetchOrgTree.mockResolvedValueOnce([]);
const empty = render(<AgentsView addToast={vi.fn()} />); const empty = render(<AgentsView addToast={vi.fn()} />);

View File

@@ -841,6 +841,13 @@ describe("AgentsView", () => {
}); });
}); });
it("keeps heartbeat controls available on board cards", async () => {
renderView(<AgentsView addToast={mockAddToast} />);
fireEvent.click(screen.getByRole("button", { name: "Board view" }));
expect(await screen.findByRole("button", { name: /Disable heartbeat for Test Agent 1/i })).toBeInTheDocument();
});
it("displays agent task with column context when enriched", async () => { it("displays agent task with column context when enriched", async () => {
mockFetchAgents.mockResolvedValue([ mockFetchAgents.mockResolvedValue([
{ ...mockAgents[0], id: "agent-triage", name: "Triage Agent", taskId: "FN-TRIAGE", taskColumn: "triage", state: "active" as AgentState }, { ...mockAgents[0], id: "agent-triage", name: "Triage Agent", taskId: "FN-TRIAGE", taskColumn: "triage", state: "active" as AgentState },
@@ -2971,19 +2978,14 @@ describe("AgentsView", () => {
expect(boardToggle).toHaveAttribute("aria-pressed", "true"); expect(boardToggle).toHaveAttribute("aria-pressed", "true");
}); });
it("renders recursive org controls and prevents a pending toggle from issuing duplicate PATCH requests", async () => { it("keeps heartbeat mutations out of org chart nodes", async () => {
let resolveUpdate: ((agent: Agent) => void) | undefined;
mockFetchOrgTree.mockResolvedValue([{ agent: { ...mockAgents[1], runtimeConfig: { enabled: false, heartbeatIntervalMs: 900_000 } }, children: [] }]); mockFetchOrgTree.mockResolvedValue([{ agent: { ...mockAgents[1], runtimeConfig: { enabled: false, heartbeatIntervalMs: 900_000 } }, children: [] }]);
mockUpdateAgent.mockImplementation(() => new Promise<Agent>((resolve) => { resolveUpdate = resolve; }));
renderView(<AgentsView addToast={mockAddToast} projectId={projectId} />); renderView(<AgentsView addToast={mockAddToast} projectId={projectId} />);
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" })); fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
const toggle = await screen.findByRole("button", { name: "Enable heartbeat for Test Agent 2" }); await screen.findByText("Test Agent 2");
fireEvent.click(toggle); expect(screen.queryByRole("button", { name: /heartbeat for Test Agent 2/i })).toBeNull();
fireEvent.click(toggle); expect(mockUpdateAgent).not.toHaveBeenCalled();
expect(mockUpdateAgent).toHaveBeenCalledTimes(1);
expect(toggle).toBeDisabled();
resolveUpdate?.(mockAgents[1]);
}); });
it("updates every eligible current-project durable agent through bulk controls despite filtered display", async () => { it("updates every eligible current-project durable agent through bulk controls despite filtered display", async () => {