feat(FN-3457): add native shell connection management for mobile and deskto

Merges shell connection infrastructure across desktop and mobile (NativeShellConnectionManager, NativeShellOnboardingModal, connection-profiles and native-shell plugins), strengthens the research store lifecycle with expanded test coverage, enhances WorkflowStepManager with new UI, and adds document

Fusion-Task-Id: FN-3457
This commit is contained in:
Fusion
2026-05-04 21:53:47 -07:00
committed by gsxdsm
parent 025eb7b3ae
commit 7e8ad8b2c3
4 changed files with 12 additions and 160 deletions

View File

@@ -169,6 +169,13 @@ describe("Agent CSS classes", () => {
expect(hasClass(".agents-mobile-back-btn")).toBe(false);
});
it("removes desktop sidebar quick-controls strip classes", () => {
expect(hasClass(".agents-sidebar-quick-controls")).toBe(false);
expect(hasClass(".agents-sidebar-quick-controls__header")).toBe(false);
expect(hasClass(".agents-sidebar-quick-controls__meta")).toBe(false);
expect(hasClass(".agents-sidebar-quick-controls__actions")).toBe(false);
});
it("should visually group the filter controls", () => {
const filtersBlock = extractRuleBlock(".agent-controls-filters");
expect(filtersBlock).toContain("padding: var(--space-xs) var(--space-sm)");

View File

@@ -187,37 +187,6 @@
opacity: 0.3;
}
.agents-sidebar-quick-controls {
border-top: 1px solid var(--border);
padding: var(--space-md);
background: var(--surface);
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.agents-sidebar-quick-controls__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
.agents-sidebar-quick-controls__meta {
display: flex;
flex-direction: column;
gap: var(--space-xs);
font-size: calc(var(--space-sm) + var(--space-xs));
color: var(--text-muted);
}
.agents-sidebar-quick-controls__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
.agents-view-loading {
display: inline-flex;
align-items: center;
@@ -1253,10 +1222,6 @@
display: none;
}
.agents-sidebar-quick-controls {
display: none;
}
.agents-view-content {
min-height: 0;
overflow-y: auto;

View File

@@ -25,7 +25,6 @@ import {
HEARTBEAT_INTERVAL_PRESETS,
} from "../utils/heartbeatIntervals";
import { isEphemeralAgent, getErrorMessage } from "@fusion/core";
import { relativeTime } from "./AgentDetailView";
import { formatAgentSkillBadgeLabel } from "../utils/agentSkills";
export interface AgentsViewProps {
@@ -699,7 +698,6 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role;
const getRoleIcon = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.icon ?? "◆";
const selectedAgent = selectedAgentId ? displayAgents.find((agent) => agent.id === selectedAgentId) ?? null : null;
const orgChartZoom = ORG_CHART_ZOOM_LEVELS[orgChartZoomIndex];
/** Get skill badges from agent metadata */
@@ -1379,60 +1377,6 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
)}
</div>
{!isMobileViewport && selectedAgent && (
<div className="agents-sidebar-quick-controls">
<div className="agents-sidebar-quick-controls__header">
<strong>{selectedAgent.name}</strong>
<span className={`badge ${getStateBadgeClass(selectedAgent.state)}`}>{selectedAgent.state}</span>
</div>
<div className="agents-sidebar-quick-controls__meta">
<span>{formatHeartbeatInterval(resolveHeartbeatIntervalMs(selectedAgent.runtimeConfig?.heartbeatIntervalMs))}</span>
{selectedAgent.lastHeartbeatAt && <span>Last {relativeTime(selectedAgent.lastHeartbeatAt)}</span>}
</div>
<div className="agents-sidebar-quick-controls__actions">
{selectedAgent.state === "idle" && (
<button className="btn btn-sm" onClick={() => void handleStateChange(selectedAgent.id, "active")}>
<Play size={14} /> Start
</button>
)}
{selectedAgent.state === "active" && (
<>
<button className="btn btn-sm" onClick={() => void handleRunHeartbeat(selectedAgent.id, selectedAgent.name)}>
<Activity size={14} /> Run Now
</button>
<button className="btn btn-sm" onClick={() => void handleStateChange(selectedAgent.id, "paused")}>
<Pause size={14} /> Pause
</button>
</>
)}
{selectedAgent.state === "running" && (
<button className="btn btn-sm" onClick={() => void handleStateChange(selectedAgent.id, "paused")}>
<Pause size={14} /> Pause
</button>
)}
{selectedAgent.state === "paused" && (
<button className="btn btn-sm" onClick={() => void handleStateChange(selectedAgent.id, "active")}>
<Play size={14} /> Resume
</button>
)}
{selectedAgent.state === "error" && (
<button className="btn btn-sm" onClick={() => void handleStateChange(selectedAgent.id, "active")}>
<Play size={14} /> Retry
</button>
)}
{selectedAgent.state === "terminated" && (
<>
<button className="btn btn-sm" onClick={() => void handleStateChange(selectedAgent.id, "active")}>
<Play size={14} /> Start
</button>
<button className="btn btn-danger btn-sm" onClick={() => void handleDelete(selectedAgent.id, selectedAgent.name)}>
<Trash2 size={14} /> Delete
</button>
</>
)}
</div>
</div>
)}
</div>
<div className={`agents-split-detail${isMobileViewport && !selectedAgentId ? " agents-split-detail--hidden-mobile" : ""}`}>

View File

@@ -238,82 +238,18 @@ describe("AgentsView", () => {
expect(container.querySelector(".agent-card--selected")).toBeTruthy();
});
it("renders desktop quick controls and run now starts heartbeat", async () => {
render(<AgentsView addToast={mockAddToast} />);
it("keeps desktop selection in detail pane without rendering sidebar quick-controls strip", async () => {
const { container } = render(<AgentsView addToast={mockAddToast} />);
const detailButton = await screen.findByRole("button", { name: "View details for Test Agent 2" });
fireEvent.click(detailButton);
const runNowButtons = await screen.findAllByRole("button", { name: /Run Now/i });
fireEvent.click(runNowButtons[0]);
await waitFor(() => {
expect(mockStartAgentRun).toHaveBeenCalled();
expect(screen.getByTestId("agent-detail-view")).toHaveAttribute("data-inline", "true");
});
});
it.each([
{ state: "idle", expected: ["Start"], unexpected: ["Run Now", "Pause", "Resume", "Retry", "Delete"] },
{ state: "active", expected: ["Run Now", "Pause"], unexpected: ["Resume", "Retry", "Delete"] },
{ state: "paused", expected: ["Resume"], unexpected: ["Run Now", "Pause", "Retry", "Delete"] },
{ state: "running", expected: ["Pause"], unexpected: ["Run Now", "Resume", "Retry", "Delete"] },
{ state: "error", expected: ["Retry"], unexpected: ["Run Now", "Pause", "Resume", "Delete"] },
{ state: "terminated", expected: ["Start", "Delete"], unexpected: ["Run Now", "Pause", "Resume", "Retry"] },
] as const)("shows correct quick-control buttons for $state state", async ({ state, expected, unexpected }) => {
const stateAgent = {
...mockAgents[0],
id: "state-agent",
name: `State ${state}`,
state,
} as Agent;
mockFetchAgents.mockResolvedValueOnce([stateAgent]);
mockFetchAgentStats.mockResolvedValueOnce({ total: 1, byState: {}, byRole: {} });
render(<AgentsView addToast={mockAddToast} />);
const detailButton = await screen.findByRole("button", { name: `View details for State ${state}` });
fireEvent.click(detailButton);
const quickControls = await screen.findByText(`State ${state}`, { selector: ".agents-sidebar-quick-controls strong" });
const quickControlsPanel = quickControls.closest(".agents-sidebar-quick-controls");
expect(quickControlsPanel).toBeTruthy();
for (const label of expected) {
expect(within(quickControlsPanel as HTMLElement).getByRole("button", { name: new RegExp(label, "i") })).toBeTruthy();
}
for (const label of unexpected) {
expect(within(quickControlsPanel as HTMLElement).queryByRole("button", { name: new RegExp(label, "i") })).toBeNull();
}
});
it("quick control start button triggers state update", async () => {
const idleAgent = { ...mockAgents[0], id: "idle-agent", name: "Idle Agent", state: "idle" as AgentState };
mockFetchAgents.mockResolvedValueOnce([idleAgent]);
mockFetchAgentStats.mockResolvedValueOnce({ total: 1, byState: {}, byRole: {} });
render(<AgentsView addToast={mockAddToast} />);
fireEvent.click(await screen.findByRole("button", { name: "View details for Idle Agent" }));
const quickControlsPanel = await screen.findByText("Idle Agent", { selector: ".agents-sidebar-quick-controls strong" });
fireEvent.click(within(quickControlsPanel.closest(".agents-sidebar-quick-controls") as HTMLElement).getByRole("button", { name: /Start/i }));
await waitFor(() => {
expect(mockUpdateAgentState).toHaveBeenCalledWith("idle-agent", "active", undefined);
});
});
it("quick control delete button deletes terminated agent", async () => {
const terminatedAgent = { ...mockAgents[0], id: "terminated-agent", name: "Terminated Agent", state: "terminated" as AgentState };
mockFetchAgents.mockResolvedValueOnce([terminatedAgent]);
mockFetchAgentStats.mockResolvedValueOnce({ total: 1, byState: {}, byRole: {} });
render(<AgentsView addToast={mockAddToast} />);
fireEvent.click(await screen.findByRole("button", { name: "View details for Terminated Agent" }));
const quickControlsPanel = await screen.findByText("Terminated Agent", { selector: ".agents-sidebar-quick-controls strong" });
fireEvent.click(within(quickControlsPanel.closest(".agents-sidebar-quick-controls") as HTMLElement).getByRole("button", { name: /Delete/i }));
await waitFor(() => {
expect(mockDeleteAgent).toHaveBeenCalledWith("terminated-agent", undefined);
});
expect(container.querySelector(".agent-card--selected")).toBeTruthy();
expect(container.querySelector(".agents-sidebar-quick-controls")).toBeNull();
});
it("supports mobile drill-in detail with back navigation", async () => {