feat(FN-3354): fix verification gate styling and test assertion
Merged fix for FN-3354 completing Step 6 of verification gate improvements. Changes adjust styling in the Agent Detail view CSS and update a corresponding test assertion in the Model Onboarding modal, suggesting the verification gate behavior or visual presentation was refined. Fusion-Task-Id: FN-3354
This commit is contained in:
@@ -1267,6 +1267,37 @@
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
}
|
||||
|
||||
.logs-return-to-live {
|
||||
position: sticky;
|
||||
right: var(--space-md);
|
||||
bottom: var(--space-md);
|
||||
width: fit-content;
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
color: var(--text-muted);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
font-size: var(--space-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.logs-return-to-live:hover {
|
||||
background: var(--card-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.logs-return-to-live:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.config-textarea-mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: calc(var(--space-md) + var(--space-xs) * 0.25);
|
||||
@@ -1506,6 +1537,11 @@
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.logs-return-to-live {
|
||||
right: var(--space-sm);
|
||||
bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.logs-empty {
|
||||
padding: var(--space-2xl) var(--space-lg);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./AgentDetailView.css";
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useState, useEffect, useCallback, useRef, useMemo, useLayoutEffect } from "react";
|
||||
import {
|
||||
Bot, Heart, Activity, Pause, Play, Square, Trash2, RefreshCw,
|
||||
Settings, FileText, ActivitySquare, X, Copy,
|
||||
@@ -205,7 +205,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
}
|
||||
const entries = await fetchAgentRunLogs(currentAgentId, latest.id, currentProjectId);
|
||||
if (isStale()) return;
|
||||
setLogs([...entries].reverse());
|
||||
setLogs(entries);
|
||||
loadedLatestRunLogsRef.current = latest.id;
|
||||
} catch (err) {
|
||||
if (isStale()) return;
|
||||
@@ -264,7 +264,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
if (contextVersionRef.current !== contextVersionAtStart) return;
|
||||
try {
|
||||
const entry: AgentLogEntry = JSON.parse(e.data);
|
||||
setLogs(prev => [entry, ...prev]);
|
||||
setLogs(prev => [...prev, entry]);
|
||||
} catch {
|
||||
// ignore malformed events
|
||||
}
|
||||
@@ -354,12 +354,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
if (contextVersionRef.current !== contextVersionAtStart) return;
|
||||
try {
|
||||
const entry: AgentLogEntry = JSON.parse(e.data);
|
||||
setLogs(prev => [entry, ...prev]);
|
||||
|
||||
const container = logContainerRef.current;
|
||||
if (container && container.scrollTop < 50) {
|
||||
container.scrollTop = 0;
|
||||
}
|
||||
setLogs(prev => [...prev, entry]);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
@@ -970,6 +965,12 @@ function DashboardTab({
|
||||
|
||||
// ── Logs Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
const BOTTOM_FOLLOW_THRESHOLD_PX = 50;
|
||||
|
||||
function isNearBottom(container: HTMLDivElement): boolean {
|
||||
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD_PX;
|
||||
}
|
||||
|
||||
function LogsTab({
|
||||
logs,
|
||||
isStreaming,
|
||||
@@ -983,6 +984,35 @@ function LogsTab({
|
||||
hasTask: boolean;
|
||||
fallbackLabel?: string | null;
|
||||
}) {
|
||||
const [isFollowing, setIsFollowing] = useState(true);
|
||||
const previousLogCountRef = useRef(0);
|
||||
|
||||
// Auto-scroll to bottom when new entries arrive and user is near the bottom
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const previousCount = previousLogCountRef.current;
|
||||
previousLogCountRef.current = logs.length;
|
||||
|
||||
if (logs.length > previousCount && isFollowing) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}, [logs.length, isFollowing, containerRef]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
setIsFollowing(isNearBottom(container));
|
||||
}, [containerRef]);
|
||||
|
||||
const scrollToLive = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
setIsFollowing(true);
|
||||
}, [containerRef]);
|
||||
|
||||
if (!hasTask) {
|
||||
return (
|
||||
<div className="logs-tab">
|
||||
@@ -1012,7 +1042,7 @@ function LogsTab({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={containerRef} className="logs-container">
|
||||
<div ref={containerRef} className="logs-container" onScroll={handleScroll}>
|
||||
{logs.length === 0 ? (
|
||||
<div className="logs-empty">
|
||||
<FileText size={48} opacity={0.3} />
|
||||
@@ -1030,6 +1060,17 @@ function LogsTab({
|
||||
);
|
||||
})
|
||||
)}
|
||||
{!isFollowing && logs.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="logs-return-to-live"
|
||||
onClick={scrollToLive}
|
||||
data-testid="logs-return-to-live"
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
<span>Live</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1243,7 +1243,177 @@ describe("AgentDetailView", () => {
|
||||
expect(screen.getByText("Latest run · run-1001")).toBeInTheDocument();
|
||||
expect(
|
||||
Array.from(document.querySelectorAll(".log-text")).map((node) => node.textContent?.trim()),
|
||||
).toEqual(["Second entry", "First entry"]);
|
||||
).toEqual(["First entry", "Second entry"]);
|
||||
});
|
||||
|
||||
it("renders log entries in chronological order (oldest first)", async () => {
|
||||
const latestRun = {
|
||||
id: "run-1002",
|
||||
agentId: "agent-001",
|
||||
startedAt: "2024-01-01T00:00:00.000Z",
|
||||
endedAt: null,
|
||||
status: "active",
|
||||
} as AgentHeartbeatRun;
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
taskId: undefined,
|
||||
activeRun: latestRun,
|
||||
completedRuns: [],
|
||||
}));
|
||||
mockFetchAgentRuns.mockResolvedValue([latestRun]);
|
||||
mockFetchAgentRunLogs.mockResolvedValue([
|
||||
{ timestamp: "2024-01-01T00:01:00.000Z", taskId: "agent-run", text: "Oldest entry", type: "text" },
|
||||
{ timestamp: "2024-01-01T00:02:00.000Z", taskId: "agent-run", text: "Middle entry", type: "text" },
|
||||
{ timestamp: "2024-01-01T00:03:00.000Z", taskId: "agent-run", text: "Newest entry", type: "text" },
|
||||
]);
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Dashboard")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Logs"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Oldest entry")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const logTexts = Array.from(document.querySelectorAll(".log-text")).map(
|
||||
(node) => node.textContent?.trim(),
|
||||
);
|
||||
expect(logTexts).toEqual(["Oldest entry", "Middle entry", "Newest entry"]);
|
||||
});
|
||||
|
||||
it("shows Live button when scrolled away from bottom and hides when at bottom", async () => {
|
||||
const latestRun = {
|
||||
id: "run-1003",
|
||||
agentId: "agent-001",
|
||||
startedAt: "2024-01-01T00:00:00.000Z",
|
||||
endedAt: null,
|
||||
status: "active",
|
||||
} as AgentHeartbeatRun;
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
taskId: undefined,
|
||||
activeRun: latestRun,
|
||||
completedRuns: [],
|
||||
}));
|
||||
mockFetchAgentRuns.mockResolvedValue([latestRun]);
|
||||
mockFetchAgentRunLogs.mockResolvedValue(
|
||||
Array.from({ length: 20 }, (_, i) => ({
|
||||
timestamp: `2024-01-01T00:${String(i).padStart(2, "0")}:00.000Z`,
|
||||
taskId: "agent-run",
|
||||
text: `Log line ${i}`,
|
||||
type: "text" as const,
|
||||
})),
|
||||
);
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Dashboard")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Logs"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Log line 0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// No Live button when initially at bottom (isFollowing defaults to true and
|
||||
// useLayoutEffect scrolls to bottom on first render with entries)
|
||||
expect(screen.queryByTestId("logs-return-to-live")).not.toBeInTheDocument();
|
||||
|
||||
// Simulate scrolling up away from bottom
|
||||
const container = document.querySelector(".logs-container") as HTMLDivElement;
|
||||
expect(container).toBeTruthy();
|
||||
Object.defineProperty(container, "scrollTop", { value: 0, writable: true, configurable: true });
|
||||
Object.defineProperty(container, "scrollHeight", { value: 1000, writable: true, configurable: true });
|
||||
Object.defineProperty(container, "clientHeight", { value: 200, writable: true, configurable: true });
|
||||
fireEvent.scroll(container);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("logs-return-to-live")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Simulate scrolling back to bottom
|
||||
Object.defineProperty(container, "scrollTop", { value: 800, writable: true, configurable: true });
|
||||
fireEvent.scroll(container);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("logs-return-to-live")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("scrolls to bottom when Live button is clicked", async () => {
|
||||
const latestRun = {
|
||||
id: "run-1004",
|
||||
agentId: "agent-001",
|
||||
startedAt: "2024-01-01T00:00:00.000Z",
|
||||
endedAt: null,
|
||||
status: "active",
|
||||
} as AgentHeartbeatRun;
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
taskId: undefined,
|
||||
activeRun: latestRun,
|
||||
completedRuns: [],
|
||||
}));
|
||||
mockFetchAgentRuns.mockResolvedValue([latestRun]);
|
||||
mockFetchAgentRunLogs.mockResolvedValue(
|
||||
Array.from({ length: 20 }, (_, i) => ({
|
||||
timestamp: `2024-01-01T00:${String(i).padStart(2, "0")}:00.000Z`,
|
||||
taskId: "agent-run",
|
||||
text: `Log line ${i}`,
|
||||
type: "text" as const,
|
||||
})),
|
||||
);
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Dashboard")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Logs"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Log line 0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Simulate scrolling up
|
||||
const container = document.querySelector(".logs-container") as HTMLDivElement;
|
||||
expect(container).toBeTruthy();
|
||||
Object.defineProperty(container, "scrollTop", { value: 0, writable: true, configurable: true });
|
||||
Object.defineProperty(container, "scrollHeight", { value: 1000, writable: true, configurable: true });
|
||||
Object.defineProperty(container, "clientHeight", { value: 200, writable: true, configurable: true });
|
||||
fireEvent.scroll(container);
|
||||
|
||||
const liveButton = await screen.findByTestId("logs-return-to-live");
|
||||
|
||||
// Set up scroll position to accept writes so scrollToLive can set scrollTop
|
||||
Object.defineProperty(container, "scrollTop", { value: 0, writable: true, configurable: true });
|
||||
|
||||
fireEvent.click(liveButton);
|
||||
|
||||
// After clicking, scrollTop should be set to scrollHeight (1000)
|
||||
expect(container.scrollTop).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user