feat(FN-3181): add initial runs tab with auto-expand selected run
Adds a "running control" to the agents dashboard that displays a live run entry and auto-expands to show active runs on load. The AgentsView and AgentDetailView components now support an initial runs tab with auto-expansion of the selected run, while the AgentDetailView gains an active-run context p Fusion-Task-Id: FN-3181
This commit is contained in:
@@ -62,6 +62,9 @@ interface AgentDetailViewProps {
|
||||
addToast: (message: string, type?: "success" | "error") => void;
|
||||
onChildClick?: (childId: string) => void;
|
||||
inline?: boolean;
|
||||
initialTab?: TabId;
|
||||
initialRunId?: string | null;
|
||||
preferActiveRun?: boolean;
|
||||
}
|
||||
|
||||
type TabId = "dashboard" | "logs" | "config" | "runs" | "tasks" | "employees" | "soul" | "instructions" | "memory" | "reflections";
|
||||
@@ -119,12 +122,12 @@ function pickDefaultAgentMemoryPath(files: MemoryFileInfo[], currentPath: string
|
||||
?? "";
|
||||
}
|
||||
|
||||
export function AgentDetailView({ agentId, projectId, onClose, addToast, onChildClick, inline = false }: AgentDetailViewProps) {
|
||||
export function AgentDetailView({ agentId, projectId, onClose, addToast, onChildClick, inline = false, initialTab, initialRunId, preferActiveRun = false }: AgentDetailViewProps) {
|
||||
const [agent, setAgent] = useState<AgentDetail | null>(null);
|
||||
const { confirm } = useConfirm();
|
||||
const [logs, setLogs] = useState<AgentLogEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<TabId>("dashboard");
|
||||
const [activeTab, setActiveTab] = useState<TabId>(initialTab ?? "dashboard");
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const [latestRun, setLatestRun] = useState<AgentHeartbeatRun | null>(null);
|
||||
@@ -636,6 +639,8 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
projectId={projectId}
|
||||
agentState={agent.state}
|
||||
agentName={agent.name}
|
||||
initialRunId={initialRunId}
|
||||
preferActiveRun={preferActiveRun}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1094,12 +1099,16 @@ function RunsTab({
|
||||
projectId,
|
||||
agentState,
|
||||
agentName,
|
||||
initialRunId,
|
||||
preferActiveRun,
|
||||
}: {
|
||||
addToast: (msg: string, type?: "success" | "error") => void;
|
||||
agentId: string;
|
||||
projectId?: string;
|
||||
agentState?: AgentState;
|
||||
agentName?: string;
|
||||
initialRunId?: string | null;
|
||||
preferActiveRun?: boolean;
|
||||
}) {
|
||||
const [runs, setRuns] = useState<AgentHeartbeatRun[]>([]);
|
||||
const { confirm } = useConfirm();
|
||||
@@ -1109,6 +1118,7 @@ function RunsTab({
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||
const [detailRun, setDetailRun] = useState<AgentHeartbeatRun | null>(null);
|
||||
const [isLoadingDetail, setIsLoadingDetail] = useState(false);
|
||||
const hasAutoExpandedInitialRunRef = useRef(false);
|
||||
|
||||
// Load runs on mount
|
||||
const loadRuns = useCallback(async () => {
|
||||
@@ -1194,6 +1204,25 @@ function RunsTab({
|
||||
}
|
||||
}, [selectedRunId, agentId, projectId, addToast]);
|
||||
|
||||
useEffect(() => {
|
||||
hasAutoExpandedInitialRunRef.current = false;
|
||||
}, [agentId, initialRunId, preferActiveRun]);
|
||||
|
||||
useEffect(() => {
|
||||
if (runs.length === 0 || isLoadingRuns || hasAutoExpandedInitialRunRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runToExpand = initialRunId
|
||||
? runs.find((run) => run.id === initialRunId)
|
||||
: (preferActiveRun ? runs.find((run) => run.status === "active") : null);
|
||||
|
||||
hasAutoExpandedInitialRunRef.current = true;
|
||||
if (runToExpand) {
|
||||
void handleRunClick(runToExpand.id);
|
||||
}
|
||||
}, [initialRunId, preferActiveRun, runs, isLoadingRuns, handleRunClick]);
|
||||
|
||||
const handleRunHeartbeat = async () => {
|
||||
try {
|
||||
await startAgentRun(agentId, projectId, { source: "on_demand", triggerDetail: "Triggered from dashboard" });
|
||||
|
||||
@@ -188,6 +188,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const isMobileDetailOpen = isMobileViewport && !!selectedAgentId;
|
||||
const [selectedAgentInitialTab, setSelectedAgentInitialTab] = useState<"dashboard" | "runs">("dashboard");
|
||||
const [selectedAgentInitialRunId, setSelectedAgentInitialRunId] = useState<string | null>(null);
|
||||
const [selectedAgentPreferActiveRun, setSelectedAgentPreferActiveRun] = useState(false);
|
||||
const [agentView, setAgentView] = useState<"list" | "board" | "org">(() => {
|
||||
if (typeof window === "undefined") return "list";
|
||||
const saved = getScopedItem("fn-agent-view", projectId);
|
||||
@@ -598,13 +601,23 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
}));
|
||||
};
|
||||
|
||||
const openAgentDetail = useCallback((agentId: string, options?: { initialTab?: "dashboard" | "runs"; initialRunId?: string | null; preferActiveRun?: boolean }) => {
|
||||
setSelectedAgentId(agentId);
|
||||
setSelectedAgentInitialTab(options?.initialTab ?? "dashboard");
|
||||
setSelectedAgentInitialRunId(options?.initialRunId ?? null);
|
||||
setSelectedAgentPreferActiveRun(options?.preferActiveRun ?? false);
|
||||
}, []);
|
||||
|
||||
const handleCloseDetail = useCallback(() => {
|
||||
setSelectedAgentId(null);
|
||||
setSelectedAgentInitialTab("dashboard");
|
||||
setSelectedAgentInitialRunId(null);
|
||||
setSelectedAgentPreferActiveRun(false);
|
||||
}, []);
|
||||
|
||||
const handleChildClick = useCallback((childId: string) => {
|
||||
setSelectedAgentId(childId);
|
||||
}, []);
|
||||
openAgentDetail(childId);
|
||||
}, [openAgentDetail]);
|
||||
|
||||
const handleRunHeartbeat = async (agentId: string, agentName: string) => {
|
||||
try {
|
||||
@@ -878,7 +891,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<OrgChartNode
|
||||
key={node.agent.id}
|
||||
node={node}
|
||||
onSelect={setSelectedAgentId}
|
||||
onSelect={openAgentDetail}
|
||||
getHealthStatus={getHealthStatus}
|
||||
getRoleIcon={getRoleIcon}
|
||||
getSkillBadges={getSkillBadges}
|
||||
@@ -900,7 +913,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<div key={agent.id} className={`agent-board-card ${stateCardClass}${selectedAgentId === agent.id ? " agent-card--selected" : ""}`}>
|
||||
<div
|
||||
className="agent-board-clickable"
|
||||
onClick={() => setSelectedAgentId(agent.id)}
|
||||
onClick={() => openAgentDetail(agent.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
@@ -908,7 +921,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
}
|
||||
setSelectedAgentId(agent.id);
|
||||
openAgentDetail(agent.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -946,7 +959,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<div className="agent-card-header">
|
||||
<div
|
||||
className="agent-info agent-info--clickable"
|
||||
onClick={() => setSelectedAgentId(agent.id)}
|
||||
onClick={() => openAgentDetail(agent.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
@@ -954,7 +967,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
}
|
||||
setSelectedAgentId(agent.id);
|
||||
openAgentDetail(agent.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1188,9 +1201,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
disabled
|
||||
title="Run in progress"
|
||||
aria-label={`Heartbeat run in progress for ${agent.name}`}
|
||||
onClick={() => openAgentDetail(agent.id, { initialTab: "runs", initialRunId: null, preferActiveRun: true })}
|
||||
title="View live run details"
|
||||
aria-label={`View live run details for ${agent.name}`}
|
||||
>
|
||||
<Activity size={14} /> Running
|
||||
</button>
|
||||
@@ -1226,7 +1239,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
)}
|
||||
<button
|
||||
className="btn btn--sm agent-card-details-btn"
|
||||
onClick={() => setSelectedAgentId(agent.id)}
|
||||
onClick={() => openAgentDetail(agent.id)}
|
||||
title={`View details for ${agent.name}`}
|
||||
aria-label={`View details for ${agent.name}`}
|
||||
>
|
||||
@@ -1322,6 +1335,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
onClose={handleCloseDetail}
|
||||
addToast={addToast}
|
||||
onChildClick={handleChildClick}
|
||||
initialTab={selectedAgentInitialTab}
|
||||
initialRunId={selectedAgentInitialRunId}
|
||||
preferActiveRun={selectedAgentPreferActiveRun}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
|
||||
@@ -1119,6 +1119,87 @@ describe("AgentDetailView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("opens directly to Runs tab and auto-expands the provided initial run", async () => {
|
||||
const runId = "run-001";
|
||||
mockFetchAgentRunLogs.mockResolvedValueOnce([
|
||||
{
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
taskId: "agent-run",
|
||||
text: "Run log line",
|
||||
type: "text",
|
||||
} as AgentLogEntry,
|
||||
]);
|
||||
mockFetchAgentRunDetail.mockResolvedValueOnce({
|
||||
id: runId,
|
||||
agentId: "agent-001",
|
||||
startedAt: "2024-01-01T00:00:00.000Z",
|
||||
endedAt: null,
|
||||
status: "active",
|
||||
systemPrompt: "System prompt text",
|
||||
} as AgentHeartbeatRun);
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
initialTab="runs"
|
||||
initialRunId={runId}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgentRunLogs).toHaveBeenCalledWith("agent-001", runId, undefined);
|
||||
expect(mockFetchAgentRunDetail).toHaveBeenCalledWith("agent-001", runId, undefined);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Run log line")).toBeInTheDocument();
|
||||
expect(screen.getByText("System Prompt")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-expands the active run when opened from running control context", async () => {
|
||||
const activeRunId = "run-001";
|
||||
mockFetchAgentRunLogs.mockResolvedValueOnce([
|
||||
{
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
taskId: "agent-run",
|
||||
text: "Active run log line",
|
||||
type: "text",
|
||||
} as AgentLogEntry,
|
||||
]);
|
||||
mockFetchAgentRunDetail.mockResolvedValueOnce({
|
||||
id: activeRunId,
|
||||
agentId: "agent-001",
|
||||
startedAt: "2024-01-01T00:00:00.000Z",
|
||||
endedAt: null,
|
||||
status: "active",
|
||||
systemPrompt: "Active run system prompt",
|
||||
} as AgentHeartbeatRun);
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
initialTab="runs"
|
||||
initialRunId={null}
|
||||
preferActiveRun
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgentRunLogs).toHaveBeenCalledWith("agent-001", activeRunId, undefined);
|
||||
expect(mockFetchAgentRunDetail).toHaveBeenCalledWith("agent-001", activeRunId, undefined);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Active run log line")).toBeInTheDocument();
|
||||
expect(screen.getByText("System Prompt")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Logs tab", () => {
|
||||
it("loads latest run logs lazily for agents without a current task", async () => {
|
||||
const latestRun = {
|
||||
|
||||
@@ -31,8 +31,10 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
});
|
||||
|
||||
vi.mock("../AgentDetailView", () => ({
|
||||
AgentDetailView: ({ agentId, inline }: { agentId: string; inline?: boolean }) => (
|
||||
<div data-testid="agent-detail-view" data-inline={inline ? "true" : "false"}>Agent detail: {agentId}</div>
|
||||
AgentDetailView: ({ agentId, inline, initialTab, initialRunId, preferActiveRun }: { agentId: string; inline?: boolean; initialTab?: string; initialRunId?: string | null; preferActiveRun?: boolean }) => (
|
||||
<div data-testid="agent-detail-view" data-inline={inline ? "true" : "false"} data-initial-tab={initialTab ?? "dashboard"} data-initial-run-id={initialRunId ?? ""} data-prefer-active-run={preferActiveRun ? "true" : "false"}>
|
||||
Agent detail: {agentId}
|
||||
</div>
|
||||
),
|
||||
relativeTime: () => "just now",
|
||||
}));
|
||||
@@ -591,7 +593,44 @@ describe("AgentsView", () => {
|
||||
fireEvent.click(clickableIdentity!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("agent-detail-view")).toHaveTextContent("agent-001");
|
||||
const detail = screen.getByTestId("agent-detail-view");
|
||||
expect(detail).toHaveTextContent("agent-001");
|
||||
expect(detail).toHaveAttribute("data-initial-tab", "dashboard");
|
||||
expect(detail).toHaveAttribute("data-initial-run-id", "");
|
||||
});
|
||||
});
|
||||
|
||||
it("opens agent detail in Runs context when clicking Running control", async () => {
|
||||
const runningAgent: Agent = {
|
||||
id: "agent-005",
|
||||
name: "Runner",
|
||||
role: "executor",
|
||||
state: "running",
|
||||
activeRun: {
|
||||
id: "run-555",
|
||||
agentId: "agent-005",
|
||||
startedAt: new Date().toISOString(),
|
||||
endedAt: null,
|
||||
status: "active",
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
};
|
||||
mockFetchAgents.mockResolvedValue([runningAgent]);
|
||||
mockFetchAgentStats.mockResolvedValue({ total: 1, byState: { running: 1 }, byRole: { executor: 1 } });
|
||||
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
const runningButton = await screen.findByRole("button", { name: "View live run details for Runner" });
|
||||
fireEvent.click(runningButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const detail = screen.getByTestId("agent-detail-view");
|
||||
expect(detail).toHaveTextContent("agent-005");
|
||||
expect(detail).toHaveAttribute("data-initial-tab", "runs");
|
||||
expect(detail).toHaveAttribute("data-initial-run-id", "");
|
||||
expect(detail).toHaveAttribute("data-prefer-active-run", "true");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user