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:
7
.changeset/fn-8625-remove-org-chart-heartbeat-toggle.md
Normal file
7
.changeset/fn-8625-remove-org-chart-heartbeat-toggle.md
Normal 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.
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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()} />);
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user