feat(FN-1629): add pagination to agent logs

- Add pagination support to TaskStore.getAgentLogs() with limit/offset parameters
- Add GET /tasks/:id/logs API route with pagination support
- Add fetchAgentLogs API client with pagination parameters
- Refactor useAgentLogs hook for incremental loading with loadMore callback
- Update useMultiAgentLogs for pagination consistency across multiple task IDs
- Add Load More UI to AgentLogViewer component
- Wire up Load More in TaskDetailModal agent log section
- Fix offset slice direction bug in getAgentLogs
- Remove incorrect reverse() call in getAgentLogs offset logic
- Add comprehensive tests for useAgentLogs and useMultiAgentLogs pagination
This commit is contained in:
Fusion
2026-04-15 02:53:06 -07:00
committed by gsxdsm
parent d2bf84538f
commit 0d6535a952
11 changed files with 594 additions and 131 deletions

View File

@@ -381,15 +381,62 @@ export async function deleteAttachment(id: string, filename: string, projectId?:
return api<Task>(withProjectId(`/tasks/${id}/attachments/${filename}`, projectId), { method: "DELETE" });
}
export function fetchAgentLogs(taskId: string, projectId?: string, options?: { limit?: number }): Promise<AgentLogEntry[]> {
export function fetchAgentLogs(
taskId: string,
projectId?: string,
options?: { limit?: number; offset?: number },
): Promise<AgentLogEntry[]> {
const params = new URLSearchParams();
if (options?.limit !== undefined) {
params.set("limit", String(options.limit));
}
if (options?.offset !== undefined) {
params.set("offset", String(options.offset));
}
const suffix = params.toString() ? `?${params.toString()}` : "";
return api<AgentLogEntry[]>(withProjectId(`/tasks/${taskId}/logs${suffix}`, projectId));
}
/**
* Fetch agent logs with pagination metadata.
* Returns entries along with total count and hasMore flag from response headers.
*/
export async function fetchAgentLogsWithMeta(
taskId: string,
projectId?: string,
options?: { limit?: number; offset?: number },
): Promise<{ entries: AgentLogEntry[]; total: number; hasMore: boolean }> {
const params = new URLSearchParams();
if (options?.limit !== undefined) {
params.set("limit", String(options.limit));
}
if (options?.offset !== undefined) {
params.set("offset", String(options.offset));
}
const suffix = params.toString() ? `?${params.toString()}` : "";
const url = withProjectId(`/tasks/${taskId}/logs${suffix}`, projectId);
// Call api function to get the fetch-compatible URL
const response = await fetch(url);
if (!response.ok) {
const data = await response.json().catch(() => ({ error: "Failed to fetch agent logs" }));
throw new Error((data as { error?: string }).error || `HTTP ${response.status}`);
}
const entries = await response.json() as AgentLogEntry[];
// Read pagination headers
const total = response.headers.has("X-Total-Count")
? parseInt(response.headers.get("X-Total-Count")!, 10)
: entries.length;
const hasMore = response.headers.has("X-Has-More")
? response.headers.get("X-Has-More") === "true"
: false;
return { entries, total, hasMore };
}
export function fetchSessionFiles(taskId: string, projectId?: string): Promise<string[]> {
return api<string[]>(withProjectId(`/tasks/${taskId}/session-files`, projectId));
}

View File

@@ -6,7 +6,7 @@ import {
ChevronDown, ChevronRight, BarChart3, Star, BookOpen
} from "lucide-react";
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent } from "../api";
import type { Agent } from "../api";
import type { AgentLogEntry, Task } from "@fusion/core";
import { AgentLogViewer } from "./AgentLogViewer";
@@ -134,14 +134,14 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
// If the agent is working on a task, we could show task logs.
if (agent?.taskId) {
try {
const data = await fetchAgentLogs(agent.taskId, currentProjectId);
const result = await fetchAgentLogsWithMeta(agent.taskId, currentProjectId, { limit: 100 });
// Reject stale response: check context version and current IDs
if (contextVersionRef.current !== contextVersionAtCapture ||
agentId !== currentAgentId ||
projectId !== currentProjectId) {
return;
}
setLogs(data);
setLogs(result.entries);
} catch (err: any) {
// Reject stale error: check context version and current IDs
if (contextVersionRef.current !== contextVersionAtCapture ||

View File

@@ -4,7 +4,7 @@ import { useRef, useEffect, useState, useCallback } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown";
import { Maximize2, Minimize2 } from "lucide-react";
import { Maximize2, Minimize2, Loader2 } from "lucide-react";
function formatTimestamp(iso: string): string {
const date = new Date(iso);
@@ -60,15 +60,44 @@ interface AgentLogViewerProps {
executorModel?: ModelInfo | null;
validatorModel?: ModelInfo | null;
planningModel?: ModelInfo | null;
/** Whether more entries exist beyond what's currently loaded */
hasMore?: boolean;
/** Callback to load older entries */
onLoadMore?: () => void;
/** Whether a load more request is in progress */
loadingMore?: boolean;
/** Total number of entries (when known) for "Showing X of Y" summary */
totalCount?: number | null;
}
/**
* Renders agent log entries in a scrollable, monospace container.
* Displays entries in reverse chronological order (newest first).
* Auto-scrolls to keep latest entries visible when streaming.
* Supports toggling between markdown-formatted and plain-text rendering.
*
* Features:
* - Displays entries in reverse chronological order (newest first)
* - Auto-scrolls to keep latest entries visible when streaming
* - Supports toggling between markdown-formatted and plain-text rendering
* - "Load More" button to fetch older entries when pagination is enabled
* - Shows "Showing X of Y entries" summary when totalCount is provided
*
* @param entries - Array of log entries (in chronological order, oldest first)
* @param loading - Whether initial load is in progress
* @param hasMore - Whether more older entries exist beyond the current page
* @param onLoadMore - Callback to load older entries
* @param loadingMore - Whether a load more request is in progress
* @param totalCount - Total number of entries (when known) for summary display
*/
export function AgentLogViewer({ entries, loading, executorModel, validatorModel, planningModel }: AgentLogViewerProps) {
export function AgentLogViewer({
entries,
loading,
executorModel,
validatorModel,
planningModel,
hasMore = false,
onLoadMore,
loadingMore = false,
totalCount = null,
}: AgentLogViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const previousEntryCountRef = useRef<number>(0);
const [renderMarkdown, setRenderMarkdown] = useState(true);
@@ -198,6 +227,14 @@ export function AgentLogViewer({ entries, loading, executorModel, validatorModel
</button>
</div>
</div>
{/* Pagination summary */}
{totalCount !== null && (
<div className="agent-log-summary" data-testid="agent-log-summary">
Showing {entries.length} of {totalCount} entries
</div>
)}
{reversedEntries.map((entry, i) => {
// Look at previous entry in reversed array (= next chronologically) for deduplication
const prev = reversedEntries[i - 1];
@@ -275,6 +312,27 @@ export function AgentLogViewer({ entries, loading, executorModel, validatorModel
</span>
);
})}
{/* Load More button */}
{hasMore && onLoadMore && (
<div className="agent-log-load-more" data-testid="agent-log-load-more">
<button
className="agent-log-mode-toggle"
onClick={onLoadMore}
disabled={loadingMore}
data-testid="agent-log-load-more-button"
>
{loadingMore ? (
<>
<Loader2 size={14} className="animate-spin" />
Loading…
</>
) : (
"Load More"
)}
</button>
</div>
)}
</div>
);
}

View File

@@ -594,7 +594,14 @@ export function TaskDetailModal({
}, [isEditing, handleEditKeyDown]);
const fileInputRef = useRef<HTMLInputElement>(null);
const { entries: agentLogEntries, loading: agentLogLoading } = useAgentLogs(
const {
entries: agentLogEntries,
loading: agentLogLoading,
loadMore: loadMoreAgentLogs,
hasMore: agentLogHasMore,
total: agentLogTotal,
loadingMore: agentLogLoadingMore,
} = useAgentLogs(
task.id,
activeTab === "logs" && logSubview === "agent-log",
projectId,
@@ -1194,6 +1201,10 @@ export function TaskDetailModal({
executorModel={resolveEffectiveExecutor(task, settings)}
validatorModel={resolveEffectiveValidator(task, settings)}
planningModel={resolveEffectivePlanning(task, agentLogEntries, settings)}
hasMore={agentLogHasMore}
onLoadMore={loadMoreAgentLogs}
loadingMore={agentLogLoadingMore}
totalCount={agentLogTotal}
/>
) : (
<div className="detail-activity">

View File

@@ -1,14 +1,14 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { MAX_LOG_ENTRIES, useAgentLogs } from "../useAgentLogs";
import { fetchAgentLogs } from "../../api";
import { fetchAgentLogsWithMeta } from "../../api";
// Mock the api module
vi.mock("../../api", () => ({
fetchAgentLogs: vi.fn().mockResolvedValue([]),
fetchAgentLogsWithMeta: vi.fn().mockResolvedValue({ entries: [], total: 0, hasMore: false }),
}));
const mockFetchAgentLogs = vi.mocked(fetchAgentLogs);
const mockFetchAgentLogsWithMeta = vi.mocked(fetchAgentLogsWithMeta);
// Mock EventSource
class MockEventSource {
@@ -41,10 +41,12 @@ class MockEventSource {
const originalEventSource = globalThis.EventSource;
const INITIAL_LOAD_LIMIT = 100;
beforeEach(() => {
MockEventSource.instances = [];
(globalThis as any).EventSource = MockEventSource;
mockFetchAgentLogs.mockReset().mockResolvedValue([]);
mockFetchAgentLogsWithMeta.mockReset().mockResolvedValue({ entries: [], total: 0, hasMore: false });
});
afterEach(() => {
@@ -55,7 +57,7 @@ describe("useAgentLogs", () => {
it("does not fetch or connect when enabled=false", () => {
const { result } = renderHook(() => useAgentLogs("FN-001", false));
expect(mockFetchAgentLogs).not.toHaveBeenCalled();
expect(mockFetchAgentLogsWithMeta).not.toHaveBeenCalled();
expect(MockEventSource.instances).toHaveLength(0);
expect(result.current.entries).toEqual([]);
});
@@ -64,7 +66,11 @@ describe("useAgentLogs", () => {
const historicalLogs = [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "old", type: "text" as const },
];
mockFetchAgentLogs.mockResolvedValueOnce(historicalLogs);
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({
entries: historicalLogs,
total: historicalLogs.length,
hasMore: false,
});
const { result } = renderHook(() => useAgentLogs("FN-001", true));
@@ -72,15 +78,34 @@ describe("useAgentLogs", () => {
expect(result.current.entries).toEqual(historicalLogs);
});
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-001", undefined, { limit: 500 });
expect(mockFetchAgentLogsWithMeta).toHaveBeenCalledWith("FN-001", undefined, { limit: INITIAL_LOAD_LIMIT });
expect(MockEventSource.instances).toHaveLength(1);
expect(MockEventSource.instances[0].url).toBe("/api/tasks/FN-001/logs/stream");
});
it("sets hasMore and total from API response", async () => {
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({
entries: [],
total: 150,
hasMore: true,
});
const { result } = renderHook(() => useAgentLogs("FN-001", true));
await waitFor(() => {
expect(result.current.total).toBe(150);
expect(result.current.hasMore).toBe(true);
});
});
it("appends live SSE entries to historical entries", async () => {
mockFetchAgentLogs.mockResolvedValueOnce([
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "old", type: "text" as const },
]);
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({
entries: [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "old", type: "text" as const },
],
total: 2,
hasMore: false,
});
const { result } = renderHook(() => useAgentLogs("FN-001", true));
@@ -103,7 +128,7 @@ describe("useAgentLogs", () => {
});
it("closes SSE when enabled changes to false", async () => {
mockFetchAgentLogs.mockResolvedValueOnce([]);
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: 0, hasMore: false });
const { rerender } = renderHook(
({ enabled }) => useAgentLogs("FN-001", enabled),
@@ -122,7 +147,7 @@ describe("useAgentLogs", () => {
});
it("closes SSE on unmount", async () => {
mockFetchAgentLogs.mockResolvedValueOnce([]);
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: 0, hasMore: false });
const { unmount } = renderHook(() => useAgentLogs("FN-001", true));
@@ -144,7 +169,11 @@ describe("useAgentLogs", () => {
text: `entry-${index}`,
type: "text" as const,
}));
mockFetchAgentLogs.mockResolvedValueOnce(historicalLogs);
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({
entries: historicalLogs,
total: historicalLogs.length,
hasMore: false,
});
const { result } = renderHook(() => useAgentLogs("FN-001", true));
@@ -157,7 +186,7 @@ describe("useAgentLogs", () => {
});
it("truncates live SSE entries to the most recent entries", async () => {
mockFetchAgentLogs.mockResolvedValueOnce([]);
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: MAX_LOG_ENTRIES + 20, hasMore: false });
const { result } = renderHook(() => useAgentLogs("FN-001", true));
@@ -188,17 +217,21 @@ describe("useAgentLogs", () => {
it("does not fetch when taskId is null", () => {
renderHook(() => useAgentLogs(null, true));
expect(mockFetchAgentLogs).not.toHaveBeenCalled();
expect(mockFetchAgentLogsWithMeta).not.toHaveBeenCalled();
expect(MockEventSource.instances).toHaveLength(0);
});
it("preserves long text and detail in historical log entries without truncation", async () => {
const longText = "A".repeat(5000);
const longDetail = "B".repeat(5000);
mockFetchAgentLogs.mockResolvedValueOnce([
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: longText, type: "text" as const },
{ timestamp: "2026-01-01T00:00:01Z", taskId: "FN-001", text: "Read", type: "tool" as const, detail: longDetail },
]);
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({
entries: [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: longText, type: "text" as const },
{ timestamp: "2026-01-01T00:00:01Z", taskId: "FN-001", text: "Read", type: "tool" as const, detail: longDetail },
],
total: 2,
hasMore: false,
});
const { result } = renderHook(() => useAgentLogs("FN-001", true));
@@ -213,7 +246,7 @@ describe("useAgentLogs", () => {
});
it("preserves long text and detail in live SSE entries without truncation", async () => {
mockFetchAgentLogs.mockResolvedValueOnce([]);
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: 1, hasMore: false });
const { result } = renderHook(() => useAgentLogs("FN-001", true));
@@ -244,9 +277,67 @@ describe("useAgentLogs", () => {
expect(result.current.entries[0].detail!.length).toBe(5000);
});
describe("loadMore", () => {
it("loadMore fetches older entries and prepends them", async () => {
const initialLogs = [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "newer", type: "text" as const },
];
const olderLogs = [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "older", type: "text" as const },
];
mockFetchAgentLogsWithMeta
.mockResolvedValueOnce({ entries: initialLogs, total: 2, hasMore: true })
.mockResolvedValueOnce({ entries: olderLogs, total: 2, hasMore: false });
const { result } = renderHook(() => useAgentLogs("FN-001", true));
await waitFor(() => {
expect(result.current.entries).toHaveLength(1);
expect(result.current.hasMore).toBe(true);
});
// Call loadMore
await act(async () => {
await result.current.loadMore();
});
// Should now have 2 entries: initial + older
expect(result.current.entries).toHaveLength(2);
expect(result.current.entries[0].text).toBe("newer");
expect(result.current.entries[1].text).toBe("older");
expect(result.current.hasMore).toBe(false);
});
it("loadMore does not trigger when already loading more", async () => {
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 200, hasMore: true });
const { result } = renderHook(() => useAgentLogs("FN-001", true));
await waitFor(() => {
expect(result.current.hasMore).toBe(true);
});
// Start loading more
const loadMorePromise = act(async () => {
await result.current.loadMore();
});
// While loading, try to load more again - should be ignored
act(() => {
result.current.loadMore();
});
await loadMorePromise;
// Initial call + loadMore call (2 total), ignoring re-render calls
expect(mockFetchAgentLogsWithMeta.mock.calls.length).toBeGreaterThanOrEqual(2);
});
});
describe("projectId support", () => {
it("includes projectId in EventSource URL when provided", async () => {
mockFetchAgentLogs.mockResolvedValueOnce([]);
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: 0, hasMore: false });
renderHook(() => useAgentLogs("FN-001", true, "proj-123"));
@@ -256,18 +347,18 @@ describe("useAgentLogs", () => {
});
});
it("includes projectId in fetchAgentLogs call when provided", async () => {
mockFetchAgentLogs.mockResolvedValueOnce([]);
it("includes projectId in fetchAgentLogsWithMeta call when provided", async () => {
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: 0, hasMore: false });
renderHook(() => useAgentLogs("FN-001", true, "proj-123"));
await waitFor(() => {
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-001", "proj-123", { limit: 500 });
expect(mockFetchAgentLogsWithMeta).toHaveBeenCalledWith("FN-001", "proj-123", { limit: INITIAL_LOAD_LIMIT });
});
});
it("does not include projectId in URL when not provided", async () => {
mockFetchAgentLogs.mockResolvedValueOnce([]);
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: 0, hasMore: false });
renderHook(() => useAgentLogs("FN-001", true));
@@ -279,18 +370,22 @@ describe("useAgentLogs", () => {
it("clears entries immediately when projectId changes", async () => {
// Set up mock to return different values based on projectId
mockFetchAgentLogs.mockImplementation((_taskId: string, projectId?: string) => {
mockFetchAgentLogsWithMeta.mockImplementation((_taskId: string, projectId?: string) => {
if (projectId === "proj-A") {
return Promise.resolve([
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "proj-A-log", type: "text" as const },
]);
return Promise.resolve({
entries: [{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "proj-A-log", type: "text" as const }],
total: 1,
hasMore: false,
});
}
if (projectId === "proj-B") {
return Promise.resolve([
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "proj-B-log", type: "text" as const },
]);
return Promise.resolve({
entries: [{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "proj-B-log", type: "text" as const }],
total: 1,
hasMore: false,
});
}
return Promise.resolve([]);
return Promise.resolve({ entries: [], total: 0, hasMore: false });
});
// Create a hook that switches project
@@ -322,7 +417,7 @@ describe("useAgentLogs", () => {
it("rejects stale SSE events after project switch", async () => {
// Initial render with proj-A
mockFetchAgentLogs.mockResolvedValue([]);
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
const { result, rerender } = renderHook(
({ projectId }) => useAgentLogs("FN-001", true, projectId),
@@ -366,7 +461,7 @@ describe("useAgentLogs", () => {
});
it("creates new connection with new projectId on project switch", async () => {
mockFetchAgentLogs.mockResolvedValue([]);
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
const { rerender } = renderHook(
({ projectId }) => useAgentLogs("FN-001", true, projectId),

View File

@@ -16,15 +16,17 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { MAX_LOG_ENTRIES, useMultiAgentLogs } from "../useMultiAgentLogs";
import { fetchAgentLogs } from "../../api";
import { fetchAgentLogsWithMeta } from "../../api";
import { MockEventSource } from "../../../vitest.setup";
// Mock the api module
vi.mock("../../api", () => ({
fetchAgentLogs: vi.fn().mockResolvedValue([]),
fetchAgentLogsWithMeta: vi.fn().mockResolvedValue({ entries: [], total: 0, hasMore: false }),
}));
const mockFetchAgentLogs = vi.mocked(fetchAgentLogs);
const mockFetchAgentLogsWithMeta = vi.mocked(fetchAgentLogsWithMeta);
const INITIAL_LOAD_LIMIT = 100;
// Helper to get the last connection for a specific task ID
function getConnection(taskId: string): MockEventSource | undefined {
@@ -41,7 +43,7 @@ function getConnections(taskId: string): MockEventSource[] {
beforeEach(() => {
MockEventSource.instances = [];
mockFetchAgentLogs.mockReset().mockResolvedValue([]);
mockFetchAgentLogsWithMeta.mockReset().mockResolvedValue({ entries: [], total: 0, hasMore: false });
// Ensure we start with real timers for every test
vi.useRealTimers();
@@ -85,10 +87,10 @@ describe("useMultiAgentLogs", () => {
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-002", text: "log2", type: "text" as const },
];
mockFetchAgentLogs.mockImplementation((taskId) => {
if (taskId === "FN-001") return Promise.resolve(logs1);
if (taskId === "FN-002") return Promise.resolve(logs2);
return Promise.resolve([]);
mockFetchAgentLogsWithMeta.mockImplementation((taskId) => {
if (taskId === "FN-001") return Promise.resolve({ entries: logs1, total: logs1.length, hasMore: false });
if (taskId === "FN-002") return Promise.resolve({ entries: logs2, total: logs2.length, hasMore: false });
return Promise.resolve({ entries: [], total: 0, hasMore: false });
});
const { result } = renderHook(() => useMultiAgentLogs(["FN-001", "FN-002"]));
@@ -98,12 +100,12 @@ describe("useMultiAgentLogs", () => {
expect(result.current["FN-002"].entries).toEqual(logs2);
});
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-001", undefined, { limit: 500 });
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-002", undefined, { limit: 500 });
expect(mockFetchAgentLogsWithMeta).toHaveBeenCalledWith("FN-001", undefined, { limit: INITIAL_LOAD_LIMIT });
expect(mockFetchAgentLogsWithMeta).toHaveBeenCalledWith("FN-002", undefined, { limit: INITIAL_LOAD_LIMIT });
});
it("opens SSE EventSource for each task ID", async () => {
mockFetchAgentLogs.mockResolvedValue([]);
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
renderHook(() => useMultiAgentLogs(["FN-001", "FN-002"]));
@@ -120,7 +122,7 @@ describe("useMultiAgentLogs", () => {
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "old", type: "text" as const },
];
// Use mockResolvedValue (not Once) to handle Strict Mode double-run
mockFetchAgentLogs.mockResolvedValue(historical);
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: historical, total: historical.length, hasMore: false });
const { result } = renderHook(() => useMultiAgentLogs(["FN-001"]));
@@ -148,7 +150,7 @@ describe("useMultiAgentLogs", () => {
});
it("closes all SSE connections on unmount (memory leak prevention)", async () => {
mockFetchAgentLogs.mockResolvedValue([]);
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
const { unmount } = renderHook(() => useMultiAgentLogs(["FN-001", "FN-002"]));
@@ -175,7 +177,7 @@ describe("useMultiAgentLogs", () => {
});
it("closes specific connection when task ID removed from array", async () => {
mockFetchAgentLogs.mockResolvedValue([]);
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
const { rerender } = renderHook(
({ taskIds }: { taskIds: string[] }) => useMultiAgentLogs(taskIds),
@@ -199,7 +201,7 @@ describe("useMultiAgentLogs", () => {
});
it("opens new connection when task ID added to array", async () => {
mockFetchAgentLogs.mockResolvedValue([]);
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
const { rerender } = renderHook(
({ taskIds }: { taskIds: string[] }) => useMultiAgentLogs(taskIds),
@@ -224,7 +226,7 @@ describe("useMultiAgentLogs", () => {
{ timestamp: "2026-01-01T00:01:00Z", taskId: "FN-001", text: "log2", type: "text" as const },
];
// Use mockResolvedValue (not Once) to handle Strict Mode double-run
mockFetchAgentLogs.mockResolvedValue(logs);
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: logs, total: logs.length, hasMore: false });
const { result } = renderHook(() => useMultiAgentLogs(["FN-001", "FN-002"]));
@@ -243,7 +245,7 @@ describe("useMultiAgentLogs", () => {
});
it("handles errors gracefully when fetching historical logs", async () => {
mockFetchAgentLogs.mockRejectedValue(new Error("Network error"));
mockFetchAgentLogsWithMeta.mockRejectedValue(new Error("Network error"));
const { result } = renderHook(() => useMultiAgentLogs(["FN-001"]));
@@ -255,8 +257,8 @@ describe("useMultiAgentLogs", () => {
});
it("does not create duplicate connections while historical fetch is still pending", async () => {
let resolveFetch: ((value: never[]) => void) | undefined;
mockFetchAgentLogs.mockImplementation(
let resolveFetch: ((value: { entries: never[]; total: number; hasMore: boolean }) => void) | undefined;
mockFetchAgentLogsWithMeta.mockImplementation(
() => new Promise((resolve) => {
resolveFetch = resolve;
}),
@@ -281,15 +283,15 @@ describe("useMultiAgentLogs", () => {
// Should not create additional connections on rerender with same IDs
expect(getConnections("FN-001").length).toBe(initialCount);
resolveFetch?.([]);
resolveFetch?.({ entries: [], total: 0, hasMore: false });
await waitFor(() => {
expect(mockFetchAgentLogs).toHaveBeenCalledTimes(1);
expect(mockFetchAgentLogsWithMeta).toHaveBeenCalledTimes(1);
});
});
it("closes a task connection when its stream emits an error", async () => {
mockFetchAgentLogs.mockResolvedValue([]);
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
renderHook(() => useMultiAgentLogs(["FN-001"]));
@@ -315,7 +317,7 @@ describe("useMultiAgentLogs", () => {
type: "text" as const,
}));
mockFetchAgentLogs.mockResolvedValue(oversized);
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: oversized, total: oversized.length, hasMore: false });
const { result } = renderHook(() => useMultiAgentLogs(["FN-001"]));
@@ -328,8 +330,8 @@ describe("useMultiAgentLogs", () => {
});
it("preserves streamed entries that arrive before historical fetch resolves", async () => {
let resolveFetch: ((value: Array<{ timestamp: string; taskId: string; text: string; type: "text" }>) => void) | undefined;
mockFetchAgentLogs.mockImplementation(
let resolveFetch: ((value: { entries: Array<{ timestamp: string; taskId: string; text: string; type: "text" }>; total: number; hasMore: boolean }) => void) | undefined;
mockFetchAgentLogsWithMeta.mockImplementation(
() => new Promise((resolve) => {
resolveFetch = resolve;
}),
@@ -354,14 +356,18 @@ describe("useMultiAgentLogs", () => {
});
act(() => {
resolveFetch?.([
{
timestamp: "2026-01-01T00:00:00Z",
taskId: "FN-001",
text: "historical",
type: "text",
},
]);
resolveFetch?.({
entries: [
{
timestamp: "2026-01-01T00:00:00Z",
taskId: "FN-001",
text: "historical",
type: "text",
},
],
total: 2,
hasMore: false,
});
});
await waitFor(() => {
@@ -373,7 +379,7 @@ describe("useMultiAgentLogs", () => {
});
it("truncates live SSE entries per task to the most recent entries", async () => {
mockFetchAgentLogs.mockResolvedValue([]);
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: MAX_LOG_ENTRIES + 15, hasMore: false });
const { result } = renderHook(() => useMultiAgentLogs(["FN-001"]));
@@ -411,10 +417,10 @@ describe("useMultiAgentLogs", () => {
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-002", text: "task2-old", type: "text" as const },
];
mockFetchAgentLogs.mockImplementation((taskId) => {
if (taskId === "FN-001") return Promise.resolve(logs1);
if (taskId === "FN-002") return Promise.resolve(logs2);
return Promise.resolve([]);
mockFetchAgentLogsWithMeta.mockImplementation((taskId) => {
if (taskId === "FN-001") return Promise.resolve({ entries: logs1, total: logs1.length, hasMore: false });
if (taskId === "FN-002") return Promise.resolve({ entries: logs2, total: logs2.length, hasMore: false });
return Promise.resolve({ entries: [], total: 0, hasMore: false });
});
const { result } = renderHook(() => useMultiAgentLogs(["FN-001", "FN-002"]));
@@ -464,10 +470,14 @@ describe("useMultiAgentLogs", () => {
it("preserves long text and detail in historical log entries without truncation", async () => {
const longText = "A".repeat(5000);
const longDetail = "B".repeat(5000);
mockFetchAgentLogs.mockResolvedValue([
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: longText, type: "text" as const },
{ timestamp: "2026-01-01T00:00:01Z", taskId: "FN-001", text: "Read", type: "tool" as const, detail: longDetail },
]);
mockFetchAgentLogsWithMeta.mockResolvedValue({
entries: [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: longText, type: "text" as const },
{ timestamp: "2026-01-01T00:00:01Z", taskId: "FN-001", text: "Read", type: "tool" as const, detail: longDetail },
],
total: 2,
hasMore: false,
});
const { result } = renderHook(() => useMultiAgentLogs(["FN-001"]));
@@ -482,7 +492,7 @@ describe("useMultiAgentLogs", () => {
});
it("preserves long text and detail in live SSE entries without truncation", async () => {
mockFetchAgentLogs.mockResolvedValue([]);
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 1, hasMore: false });
const { result } = renderHook(() => useMultiAgentLogs(["FN-001"]));
@@ -517,7 +527,7 @@ describe("useMultiAgentLogs", () => {
describe("projectId support", () => {
it("includes projectId in EventSource URL when provided", async () => {
mockFetchAgentLogs.mockResolvedValue([]);
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
renderHook(() => useMultiAgentLogs(["FN-001", "FN-002"], "proj-123"));
@@ -528,18 +538,18 @@ describe("useMultiAgentLogs", () => {
});
});
it("includes projectId in fetchAgentLogs call when provided", async () => {
mockFetchAgentLogs.mockResolvedValue([]);
it("includes projectId in fetchAgentLogsWithMeta call when provided", async () => {
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
renderHook(() => useMultiAgentLogs(["FN-001"], "proj-123"));
await waitFor(() => {
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-001", "proj-123", { limit: 500 });
expect(mockFetchAgentLogsWithMeta).toHaveBeenCalledWith("FN-001", "proj-123", { limit: INITIAL_LOAD_LIMIT });
});
});
it("does not include projectId in URL when not provided", async () => {
mockFetchAgentLogs.mockResolvedValue([]);
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
renderHook(() => useMultiAgentLogs(["FN-001"]));
@@ -550,7 +560,7 @@ describe("useMultiAgentLogs", () => {
});
it("creates new EventSource when taskIds change with projectId", async () => {
mockFetchAgentLogs.mockResolvedValue([]);
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
const { rerender } = renderHook(
({ taskIds, projectId }: { taskIds: string[]; projectId?: string }) =>
@@ -578,7 +588,7 @@ describe("useMultiAgentLogs", () => {
it("fetches with correct projectId based on when effect runs", async () => {
// This test verifies that projectId is used at the time the effect runs
mockFetchAgentLogs.mockResolvedValue([]);
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
// Render with projectId proj-A
const { result: result1 } = renderHook(
@@ -593,7 +603,7 @@ describe("useMultiAgentLogs", () => {
});
// Capture calls made so far
const initialCallCount = mockFetchAgentLogs.mock.calls.length;
const initialCallCount = mockFetchAgentLogsWithMeta.mock.calls.length;
// Create new hook instance with proj-B
const { result: result2, rerender: rerender2 } = renderHook(
@@ -608,8 +618,8 @@ describe("useMultiAgentLogs", () => {
});
// The new hook should have made a fetch with proj-B
expect(mockFetchAgentLogs.mock.calls.length).toBeGreaterThan(initialCallCount);
const lastCall = mockFetchAgentLogs.mock.calls.at(-1);
expect(mockFetchAgentLogsWithMeta.mock.calls.length).toBeGreaterThan(initialCallCount);
const lastCall = mockFetchAgentLogsWithMeta.mock.calls.at(-1);
expect(lastCall?.[1]).toBe("proj-B");
});
});

View File

@@ -1,8 +1,9 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { AgentLogEntry } from "@fusion/core";
import { fetchAgentLogs } from "../api";
import { fetchAgentLogsWithMeta } from "../api";
export const MAX_LOG_ENTRIES = 500;
const INITIAL_LOAD_LIMIT = 100;
/**
* Cap the total number of log entries to `MAX_LOG_ENTRIES`.
@@ -21,23 +22,33 @@ function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] {
/**
* Hook that manages agent log fetching and live SSE streaming for a task.
*
* Features project-context isolation to prevent cross-project log bleed:
* - Treats `{projectId, taskId}` as a context key
* - Clears entries immediately on context change (project or task switch)
* - Rejects late fetch responses from previous contexts
* - Rejects stale SSE events from previous EventSource instances
* Features:
* - **Pagination**: Initial load fetches 100 entries. Use `loadMore()` to fetch older entries.
* - **Project-context isolation**: Prevents cross-project log bleed via context versioning.
* - **Live streaming**: SSE events append new entries to the end of the list.
*
* **Pagination semantics**:
* - Entries are returned in chronological order (oldest first) from the API
* - Entries are stored in chronological order
* - The UI displays newest first by reversing the array
* - `loadMore()` fetches the next 100 older entries and prepends them
*
* When `enabled` is true:
* 1. Fetches recent historical logs via GET /api/tasks/:id/logs?limit=500
* 1. Fetches recent historical logs via GET /api/tasks/:id/logs?limit=100
* 2. Opens an EventSource to /api/tasks/:id/logs/stream for live updates
* 3. Merges historical + live entries in order
*
* When `enabled` becomes false or the component unmounts, the EventSource
* is closed to avoid unnecessary SSE connections.
*
* @returns Object with entries, loading, clear, loadMore, hasMore, total
*/
export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?: string) {
const [entries, setEntries] = useState<AgentLogEntry[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [total, setTotal] = useState<number | null>(null);
const [loadingMore, setLoadingMore] = useState(false);
// Refs for state that needs to survive re-renders
const eventSourceRef = useRef<EventSource | null>(null);
@@ -71,6 +82,9 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
// Clear entries immediately on context change to prevent stale data visibility
setEntries([]);
setLoading(false);
setHasMore(false);
setTotal(null);
setLoadingMore(false);
// Close existing EventSource
if (eventSourceRef.current) {
@@ -102,8 +116,9 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
if (!currentTaskId) return;
setLoading(true);
setLoadingMore(false);
try {
const historical = await fetchAgentLogs(currentTaskId, currentProjectId, { limit: MAX_LOG_ENTRIES });
const result = await fetchAgentLogsWithMeta(currentTaskId, currentProjectId, { limit: INITIAL_LOAD_LIMIT });
// Reject stale response: check context version and request version
if (cancelledRef.current ||
@@ -111,7 +126,9 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
requestVersionRef.current !== requestVersion) {
return;
}
setEntries(capLogEntries(historical));
setEntries(capLogEntries(result.entries));
setHasMore(result.hasMore);
setTotal(result.total);
} catch {
// Reject stale error: check context version and request version
if (cancelledRef.current ||
@@ -120,6 +137,8 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
return;
}
setEntries([]);
setHasMore(false);
setTotal(null);
} finally {
// Only update loading state if not cancelled and not stale
if (!cancelledRef.current &&
@@ -143,6 +162,8 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
try {
const entry: AgentLogEntry = JSON.parse(e.data);
setEntries((prev) => capLogEntries([...prev, entry]));
// Update total if we know it (increment since new entry added)
setTotal((prev) => (prev !== null ? prev + 1 : null));
} catch {
// skip malformed events
}
@@ -160,7 +181,45 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
};
}, [taskId, enabled, projectId]);
/**
* Load more older entries.
* Fetches the next 100 older entries and prepends them to the existing list.
*/
const loadMore = useCallback(async () => {
if (!taskId || loadingMore) return;
const contextVersionAtStart = projectContextVersionRef.current;
const currentEntriesCount = entries.length;
const currentTaskId = taskId;
setLoadingMore(true);
try {
const result = await fetchAgentLogsWithMeta(currentTaskId, projectId, {
limit: INITIAL_LOAD_LIMIT,
offset: currentEntriesCount,
});
// Reject stale response
if (cancelledRef.current ||
projectContextVersionRef.current !== contextVersionAtStart) {
return;
}
// Prepend older entries to the existing list
setEntries((prev) => {
const combined = [...prev, ...result.entries];
return capLogEntries(combined);
});
setHasMore(result.hasMore);
setTotal(result.total);
} catch {
// Silently fail on load more errors
} finally {
setLoadingMore(false);
}
}, [taskId, projectId, entries.length, loadingMore]);
const clear = useCallback(() => setEntries([]), []);
return { entries, loading, clear };
return { entries, loading, clear, loadMore, hasMore, total, loadingMore };
}

View File

@@ -1,8 +1,9 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { AgentLogEntry } from "@fusion/core";
import { fetchAgentLogs } from "../api";
import { fetchAgentLogsWithMeta } from "../api";
export const MAX_LOG_ENTRIES = 500;
const INITIAL_LOAD_LIMIT = 100;
/**
* Cap the total number of log entries to `MAX_LOG_ENTRIES`.
@@ -21,7 +22,11 @@ function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] {
export interface TaskLogState {
entries: AgentLogEntry[];
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
total: number | null;
clear: () => void;
loadMore: () => Promise<void>;
}
export type LogStateMap = Record<string, TaskLogState>;
@@ -29,18 +34,21 @@ export type LogStateMap = Record<string, TaskLogState>;
interface InitState {
entries: AgentLogEntry[];
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
total: number | null;
}
/**
* Hook that manages agent log fetching and live SSE streaming for multiple tasks.
*
* Features project-context isolation to prevent cross-project log bleed:
* - Uses `{projectId, taskId}` identity for state isolation
* - Clears all state immediately on project switch
* - Rejects late fetch responses and SSE events from previous contexts
* Features:
* - **Pagination**: Initial load fetches 100 entries per task. Use `loadMore()` to fetch older entries per task.
* - **Project-context isolation**: Prevents cross-project log bleed via context versioning.
* - **Live streaming**: SSE events append new entries to the end of each task's list.
*
* For each task ID in the provided array:
* 1. Fetches recent historical logs via GET /api/tasks/:id/logs?limit=500
* 1. Fetches recent historical logs via GET /api/tasks/:id/logs?limit=100
* 2. Opens an EventSource to /api/tasks/:id/logs/stream for live updates
* 3. Merges historical + live entries in order
*
@@ -56,6 +64,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
const initializingRef = useRef<Set<string>>(new Set());
const cancelledRef = useRef<Record<string, boolean>>({});
const pendingLiveEntriesRef = useRef<Record<string, AgentLogEntry[]>>({});
const loadingMoreRef = useRef<Record<string, boolean>>({});
// Track project context version to detect stale events after project switches.
// Incremented whenever projectId changes, invalidating any in-flight SSE handlers.
@@ -79,6 +88,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
initializingRef.current.clear();
cancelledRef.current = {};
pendingLiveEntriesRef.current = {};
loadingMoreRef.current = {};
// Clear all state immediately to prevent stale data visibility
setStateMap({});
@@ -99,6 +109,63 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
};
}, []);
// Create loadMore function for a specific task
const createLoadMoreFn = useCallback((taskId: string, currentEntries: AgentLogEntry[]) => {
return async () => {
if (loadingMoreRef.current[taskId]) return;
if (!projectContextVersionRef.current) return;
const contextVersionAtStart = projectContextVersionRef.current;
loadingMoreRef.current[taskId] = true;
// Update loading state
setStateMap((prev) => {
const current = prev[taskId];
if (!current) return prev;
return { ...prev, [taskId]: { ...current, loadingMore: true } };
});
try {
const result = await fetchAgentLogsWithMeta(taskId, projectId, {
limit: INITIAL_LOAD_LIMIT,
offset: currentEntries.length,
});
// Reject stale response
if (cancelledRef.current[taskId] ||
projectContextVersionRef.current !== contextVersionAtStart) {
return;
}
// Prepend older entries to the existing list
setStateMap((prev) => {
const current = prev[taskId];
if (!current) return prev;
const combined = [...current.entries, ...result.entries];
return {
...prev,
[taskId]: {
...current,
entries: capLogEntries(combined),
hasMore: result.hasMore,
total: result.total,
loadingMore: false,
},
};
});
} catch {
// Silently fail on load more errors
setStateMap((prev) => {
const current = prev[taskId];
if (!current) return prev;
return { ...prev, [taskId]: { ...current, loadingMore: false } };
});
} finally {
loadingMoreRef.current[taskId] = false;
}
};
}, [projectId]);
// Stable comparison of task IDs and projectId to prevent effect re-runs on every render
const taskIdsKey = taskIds.join(",");
const stableKey = [taskIdsKey, projectId ?? ""].join("|");
@@ -127,7 +194,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
const updates: Record<string, InitState> = {};
for (const taskId of newTaskIds) {
if (!prev[taskId]) {
updates[taskId] = { entries: [], loading: true };
updates[taskId] = { entries: [], loading: true, loadingMore: false, hasMore: false, total: null };
}
}
if (Object.keys(updates).length === 0) return prev;
@@ -145,6 +212,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
initializing.delete(taskId);
delete cancelled[taskId];
delete pendingLiveEntriesRef.current[taskId];
delete loadingMoreRef.current[taskId];
removedTaskIds.push(taskId);
}
}
@@ -176,6 +244,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
cancelled[taskId] = true;
initializing.delete(taskId);
delete pendingLiveEntriesRef.current[taskId];
delete loadingMoreRef.current[taskId];
}
}
@@ -212,7 +281,11 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
if (!current) return prev;
return {
...prev,
[taskId]: { ...current, entries: capLogEntries([...current.entries, entry]) },
[taskId]: {
...current,
entries: capLogEntries([...current.entries, entry]),
total: current.total !== null ? current.total + 1 : null,
},
};
});
} catch {
@@ -235,9 +308,9 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
es.addEventListener("agent:log", handleAgentLog);
es.addEventListener("error", handleError);
// Fetch historical logs with projectId
void fetchAgentLogs(taskId, projectId, { limit: MAX_LOG_ENTRIES })
.then((historical) => {
// Fetch historical logs with projectId using pagination
void fetchAgentLogsWithMeta(taskId, projectId, { limit: INITIAL_LOAD_LIMIT })
.then((result) => {
// Reject stale response from previous context
if (cancelled[taskId] ||
projectContextVersionRef.current !== contextVersionAtStart) {
@@ -249,8 +322,10 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
...prev,
[taskId]: {
...prev[taskId],
entries: capLogEntries([...historical, ...pendingLive]),
entries: capLogEntries([...result.entries, ...pendingLive]),
loading: false,
hasMore: result.hasMore,
total: result.total,
},
}));
})
@@ -264,7 +339,13 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
const pendingLive = pendingLiveEntriesRef.current[taskId] ?? [];
setStateMap((prev) => ({
...prev,
[taskId]: { ...prev[taskId], entries: capLogEntries(pendingLive), loading: false },
[taskId]: {
...prev[taskId],
entries: capLogEntries(pendingLive),
loading: false,
hasMore: false,
total: null,
},
}));
})
.finally(() => {
@@ -278,7 +359,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
// Cleanup on effect re-run or unmount
return () => {
// Only close connections for tasks that were removed (not in current taskIds)
// Only close connections for tasks that were removed (not-in current taskIds)
for (const taskId of initialTaskIds) {
if (!currentIds.has(taskId)) {
cancelledRef.current[taskId] = true;
@@ -310,6 +391,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
initializingRef.current.clear();
cancelledRef.current = {};
pendingLiveEntriesRef.current = {};
loadingMoreRef.current = {};
};
}, []);
@@ -317,10 +399,15 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
const result: LogStateMap = {};
for (const taskId of taskIds) {
const state = stateMap[taskId];
const entries = state?.entries ?? [];
result[taskId] = {
entries: state?.entries ?? [],
entries,
loading: state?.loading ?? true,
loadingMore: state?.loadingMore ?? false,
hasMore: state?.hasMore ?? false,
total: state?.total ?? null,
clear: createClearFn(taskId),
loadMore: createLoadMoreFn(taskId, entries),
};
}

View File

@@ -4036,6 +4036,26 @@ body {
border-color: var(--accent);
}
/* Agent log pagination summary */
.agent-log-summary {
padding: var(--space-xs) var(--space-md);
font-size: var(--text-xs, 12px);
color: var(--text-muted, #8b949e);
border-bottom: 1px solid var(--border);
text-align: center;
}
/* Agent log load more button */
.agent-log-load-more {
padding: var(--space-md);
text-align: center;
border-top: 1px solid var(--border);
}
.agent-log-load-more .agent-log-mode-toggle {
min-width: 120px;
}
/* Fullscreen mode for agent log viewer */
.agent-log-viewer--fullscreen {
position: fixed;