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:
@@ -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)");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" : ""}`}>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user